diff --git a/.agents/skills/epds-login/SKILL.md b/.agents/skills/epds-login/SKILL.md index 03dfa329..1d5fadc3 100644 --- a/.agents/skills/epds-login/SKILL.md +++ b/.agents/skills/epds-login/SKILL.md @@ -15,6 +15,11 @@ AT Protocol universe (a DID, a handle, a data repository) automatically provisio From your app's perspective, ePDS uses standard AT Protocol OAuth (PAR + PKCE + DPoP). The reference implementation is `packages/demo` in the [ePDS repository](https://github.com/hypercerts-org/ePDS). +For protocol-level detail beyond ePDS specifics — DPoP proof mechanics, granular +scope design (`repo:`/`rpc:`/`blob:`/`account:`), identity verification after token +exchange, and refresh-token race handling — see the `atproto-oauth` skill. This skill +covers only what is ePDS-specific. + ## Two Flows | Flow | App provides | How user starts | Implementation | @@ -57,7 +62,7 @@ are mutually exclusive: "client_id": "https://yourapp.example.com/client-metadata.json", "client_name": "Your App", "redirect_uris": ["https://yourapp.example.com/api/oauth/callback"], - "scope": "atproto transition:generic", + "scope": "atproto include:org.hypercerts.authWrite include:app.certified.authWrite", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "private_key_jwt", @@ -67,6 +72,17 @@ are mutually exclusive: } ``` +> **On the `scope` value:** `atproto` is mandatory and must be listed first; the +> remaining entries request only the permissions your app needs. The examples here +> reference two hypercerts specific permission sets via the `include:` prefix — +> `include:org.hypercerts.authWrite` and `include:app.certified.authWrite`; +> substitute the permission sets your own app defines. A +> permission set that bundles `rpc:` service calls also needs an +> `?aud=` parameter (with `#` percent-encoded as `%23`); these are +> write-only sets, so no `aud` is required. Avoid the legacy `transition:generic` +> catch-all. See the `atproto-oauth` skill's "Scopes and Permission Sets" for the +> grammar. + Alternatively, replace `jwks_uri` with an inline `jwks` object containing the public key directly — see [client-metadata.md](references/client-metadata.md) for both forms, the @@ -85,7 +101,8 @@ const client = new NodeOAuthClient({ client_id: 'https://yourapp.example.com/client-metadata.json', client_name: 'Your App', redirect_uris: ['https://yourapp.example.com/api/oauth/callback'], - scope: 'atproto transition:generic', + scope: + 'atproto include:org.hypercerts.authWrite include:app.certified.authWrite', grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'private_key_jwt', diff --git a/.agents/skills/epds-login/references/client-metadata.md b/.agents/skills/epds-login/references/client-metadata.md index b99d9dcb..130aceee 100644 --- a/.agents/skills/epds-login/references/client-metadata.md +++ b/.agents/skills/epds-login/references/client-metadata.md @@ -53,7 +53,7 @@ mutually exclusive — the PDS rejects metadata that has both. "client_id": "https://yourapp.example.com/client-metadata.json", "client_name": "Your App Name", "redirect_uris": ["https://yourapp.example.com/api/oauth/callback"], - "scope": "atproto transition:generic", + "scope": "atproto include:org.hypercerts.authWrite include:app.certified.authWrite", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "private_key_jwt", @@ -70,7 +70,7 @@ mutually exclusive — the PDS rejects metadata that has both. "client_id": "https://yourapp.example.com/client-metadata.json", "client_name": "Your App Name", "redirect_uris": ["https://yourapp.example.com/api/oauth/callback"], - "scope": "atproto transition:generic", + "scope": "atproto include:org.hypercerts.authWrite include:app.certified.authWrite", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "private_key_jwt", @@ -103,7 +103,7 @@ key generation and serving details. | `client_id` | Yes | Must match the URL where this file is hosted | | `client_name` | Yes | Shown on the login page and in OTP emails | | `redirect_uris` | Yes | Array of allowed callback URLs after login | -| `scope` | Yes | Always `"atproto transition:generic"` | +| `scope` | Yes | Space-separated list. `atproto` is required and must come first; add the scopes/permission sets your app needs (the examples use `include:org.hypercerts.authWrite include:app.certified.authWrite`). Avoid the legacy `transition:generic` catch-all — see the `atproto-oauth` skill. | | `grant_types` | Yes | Always `["authorization_code", "refresh_token"]` | | `response_types` | Yes | Always `["code"]` | | `token_endpoint_auth_method` | Yes | `"private_key_jwt"` (recommended) or `"none"` — see above | @@ -266,7 +266,7 @@ above for the trade-offs. "client_id": "https://yourapp.example.com/client-metadata.json", "client_name": "Your App Name", "redirect_uris": ["https://yourapp.example.com/api/oauth/callback"], - "scope": "atproto transition:generic", + "scope": "atproto include:org.hypercerts.authWrite include:app.certified.authWrite", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "none", diff --git a/.agents/skills/epds-login/references/flows.md b/.agents/skills/epds-login/references/flows.md index a12ce519..4b6a9a15 100644 --- a/.agents/skills/epds-login/references/flows.md +++ b/.agents/skills/epds-login/references/flows.md @@ -156,7 +156,8 @@ export async function handleLogin(email: string) { client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, response_type: 'code', - scope: 'atproto transition:generic', + scope: + 'atproto include:org.hypercerts.authWrite include:app.certified.authWrite', state, code_challenge: codeChallenge, code_challenge_method: 'S256', diff --git a/.env.example b/.env.example index e03a37f0..b25b42a7 100644 --- a/.env.example +++ b/.env.example @@ -214,6 +214,12 @@ SMTP_PASS= SMTP_FROM=noreply@pds.example SMTP_FROM_NAME=ePDS +# Resend users only (optional): email webhook signing secret. ePDS supports +# any SMTP provider and does not require Resend. To opt into Resend email event +# logs, configure https://auth.pds.example/webhooks/resend and set: +# RESEND_WEBHOOK_SECRET=whsec_... +# Account-wide Resend events are filtered to the exact SMTP_FROM address. + DB_LOCATION=/data/epds.sqlite # ============================================================================ diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fc6c939..34fe1351 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # ePDS +## 0.7.0 + +### Who should read this release + +- **Operators:** + - [Email delivery logs use a consistent recipient field.](#v0.7.0-email-delivery-logs-use-a-consistent-recipient-field) + - [OTP send logs now record how long the email handoff took, the provider's message ID, and the SMTP server response.](#v0.7.0-otp-send-logs-now-record-how-long-the-email-handoff-took) + - [Optional email-event logging for operators who choose the third-party Resend service to send email; ePDS continues to support other email providers.](#v0.7.0-optional-email-event-logging-for-operators-who-choose-the) + +### Minor Changes + +- [#197](https://github.com/hypercerts-org/ePDS/pull/197) [`ce25beb`](https://github.com/hypercerts-org/ePDS/commit/ce25beba5e231e8300a875f1fc1d2ad37ae75527) Thanks [@aspiers](https://github.com/aspiers)! - Email delivery logs use a consistent recipient field. + + **Affects:** Operators + + **Operators:** All four email delivery paths now log the recipient as `email`: client-branded OTP, standard sign-in OTP, welcome OTP, and backup-email verification. Update structured-log queries for `Sent client-branded OTP email` to read `email` instead of `to`. + +- [#203](https://github.com/hypercerts-org/ePDS/pull/203) [`2193515`](https://github.com/hypercerts-org/ePDS/commit/2193515970c56035fd026c3df2b3f44e403eb801) Thanks [@aspiers](https://github.com/aspiers)! - OTP send logs now record how long the email handoff took, the provider's message ID, and the SMTP server response. + + **Affects:** Operators + + **Operators:** every OTP email completion line (all four paths — client-branded, sign-in, welcome, backup-email verification) now carries `elapsedMs`, `messageId`, and `smtpResponse` alongside `email`. `messageId` lets these logs be joined to Resend delivery events; `elapsedMs` isolates a slow SMTP handoff from other causes of late-arriving codes. Failed sends log `elapsedMs` too, on the existing `better-auth: failed to send OTP email` error line. + +- [#198](https://github.com/hypercerts-org/ePDS/pull/198) [`2bf9af4`](https://github.com/hypercerts-org/ePDS/commit/2bf9af420281c769ec88f19c9badd89f00acc03c) Thanks [@aspiers](https://github.com/aspiers)! - Optional email-event logging for operators who choose the third-party Resend service to send email; ePDS continues to support other email providers. + + **Affects:** Operators + + **Operators:** ePDS remains compatible with any SMTP provider and does not require Resend. Operators who already use Resend can opt in by registering `https:///webhooks/resend` for the documented email events and setting `RESEND_WEBHOOK_SECRET`; logs include delivery, open, complaint, suppression, and scheduling events. The route verifies each webhook, logs only events whose sender exactly matches `SMTP_FROM`, acknowledges unsupported click and inbound events without logging their payloads, and does not persist webhook data. + ## 0.6.5 ### Who should read this release diff --git a/docs/configuration.md b/docs/configuration.md index 70d5948a..642ec3ac 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -198,6 +198,62 @@ buttons appear on the login page. | `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. | +#### Optional Resend email event logs + +ePDS does **not** require Resend: operators can use any SMTP provider. This +provider-specific integration is only for operators who already send through +Resend and choose to log its email webhooks. Without +`RESEND_WEBHOOK_SECRET`, the webhook route is not mounted. + +| Variable | Description | +| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| `RESEND_WEBHOOK_SECRET` | Resend users only. Optional webhook signing secret (`whsec_...`) that enables `POST /webhooks/resend` and structured Resend event logs when provided. | + +To opt into email-event logging for email sent through Resend: + +1. In the Resend dashboard, register `https:///webhooks/resend`. +2. Subscribe it to `email.sent`, `email.delivered`, `email.delivery_delayed`, + `email.opened`, `email.bounced`, `email.failed`, `email.complained`, + `email.suppressed`, and `email.scheduled`. +3. To receive `email.opened`, enable Resend open tracking for the sending domain + and configure its tracking CNAME. Resend inserts the tracking pixel; ePDS + does not alter email HTML. Without tracking, the other events still work. +4. Set `RESEND_WEBHOOK_SECRET` to that endpoint's signing secret and redeploy + the auth service. +5. Send a test sign-in code through the production SMTP configuration and + confirm that `email.sent` and `email.delivered` reach the receiver. If open + tracking is enabled, open the message and confirm `email.opened` also arrives. + Resend's documentation does not explicitly guarantee that SMTP-submitted + email emits webhooks, so verify this before relying on the logs. + +Resend webhooks cover the whole Resend account rather than one sending domain. +The receiver parses each event's `data.from` address and logs it only when it +exactly matches this ePDS instance's `SMTP_FROM`; events for other senders are +acknowledged without logging their payload. ePDS instances sharing a Resend +account must therefore use distinct `SMTP_FROM` addresses. Events cannot be +separated when multiple instances use the same sender address. + +The receiver verifies the Svix signature against the raw request body and emits +a provider-neutral log schema: `provider`, `eventId`, `eventType`, `occurredAt`, +`messageId`, `email`, and `subject`. Any sign-in code in `subject` is replaced +with `[REDACTED]` to prevent it from reaching logs while preserving the rest of +the subject. Resend event types are normalized to `sent`, `delivered`, `delayed`, +`opened`, `bounced`, `failed`, `complained`, `suppressed`, or `scheduled`. +`Delayed`, `complained`, and `suppressed` events use `warn`; the rest use `info`. +An `opened` event means the tracking pixel was fetched, which can be caused or +suppressed by mail-client privacy features and is not proof that the recipient +read the message. Signed `email.clicked` and inbound `email.received` events are +acknowledged without logging their payloads, preventing retries if the Resend +endpoint is accidentally subscribed to them. No webhook data is persisted by +ePDS. The +provider-specific rate limit permits 300 webhook requests per minute per source +IP. + +Resend retries carry the same source event ID, logged as `eventId`, and events +may arrive out of order. Log analysis should deduplicate by `eventId`, order +each `messageId` by `occurredAt`, and subtract `sent` from `delivered` to +calculate delivery latency. + ### Database | Variable | Description | diff --git a/docs/design/testing-gaps.md b/docs/design/testing-gaps.md index 35e0fd26..b460f195 100644 --- a/docs/design/testing-gaps.md +++ b/docs/design/testing-gaps.md @@ -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/` | ~35% | 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()` | @@ -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, logged and ignored event filtering, +structured logging, and retry correlation. The browser/OAuth route files remain +this area's 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:** @@ -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). diff --git a/docs/tutorial.md b/docs/tutorial.md index a61f23e7..9b33f34e 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -190,7 +190,7 @@ The file must be served with `Content-Type: application/json`: "client_uri": "https://yourapp.example.com", "logo_uri": "https://yourapp.example.com/logo.png", "redirect_uris": ["https://yourapp.example.com/api/oauth/callback"], - "scope": "atproto transition:generic", + "scope": "atproto include:org.hypercerts.authWrite include:app.certified.authWrite", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "private_key_jwt", @@ -674,7 +674,8 @@ const client = new NodeOAuthClient({ client_id: 'https://yourapp.example.com/client-metadata.json', client_name: 'Your App', redirect_uris: ['https://yourapp.example.com/api/oauth/callback'], - scope: 'atproto transition:generic', + scope: + 'atproto include:org.hypercerts.authWrite include:app.certified.authWrite', grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'private_key_jwt', @@ -772,7 +773,8 @@ const parBody = new URLSearchParams({ client_id: clientId, redirect_uri: redirectUri, response_type: 'code', - scope: 'atproto transition:generic', + scope: + 'atproto include:org.hypercerts.authWrite include:app.certified.authWrite', state, code_challenge: codeChallenge, code_challenge_method: 'S256', diff --git a/e2e/step-definitions/consent.steps.ts b/e2e/step-definitions/consent.steps.ts index 5982b7a3..45eeb01a 100644 --- a/e2e/step-definitions/consent.steps.ts +++ b/e2e/step-definitions/consent.steps.ts @@ -45,24 +45,28 @@ Then('a consent screen is displayed', async function (this: EpdsWorld) { timeout: 30_000, }) - // 2. The demo clients request `atproto transition:generic`. For that - // scope set, @atproto/oauth-provider-ui's ScopeDescription renders - // multiple permission cards — including one titled "Authenticate" - // via the RpcMethodsDetails component, which fires on - // hasTransitionGeneric. Assert that card is visible: this proves - // the scope was actually parsed and rendered a permission summary, - // not that the page loaded blank with just an Authorize button. + // 2. The demo clients request two granular permission sets — + // `include:org.hypercerts.authWrite` and `include:app.certified.authWrite`. + // @atproto/oauth-provider-ui's ScopeDescription resolves each + // permission-set lexicon and renders a permission card titled from the + // set's `title` field ("Manage your Hypercerts data" / "Manage your + // Certified data"). Assert those cards are visible: this proves the + // scopes were actually parsed, the permission-set lexicons resolved, and + // a permission summary rendered — not that the page loaded blank with + // just an Authorize button. // - // We deliberately do NOT assert on the raw scope strings - // (`atproto`, `transition:generic`) being visible on the page — - // those only appear inside a collapsed "Technical details" - // panel that is hidden (HTML `hidden` attribute + - // aria-hidden="true") until the user clicks its disclosure - // button. Asserting on the user-facing scope card is both more - // meaningful (what users actually see) and more resilient + // We deliberately do NOT assert on the raw scope strings (`atproto`, + // `include:...`) being visible on the page — those only appear inside a + // collapsed "Technical details" panel that is hidden (HTML + // `hidden` attribute + aria-hidden="true") until the user clicks its + // disclosure button. Asserting on the user-facing permission cards is + // both more meaningful (what users actually see) and more resilient // (doesn't depend on the details-panel implementation). await expect( - page.getByRole('heading', { name: 'Authenticate' }), + page.getByRole('heading', { name: 'Manage your Hypercerts data' }), + ).toBeVisible() + await expect( + page.getByRole('heading', { name: 'Manage your Certified data' }), ).toBeVisible() }) diff --git a/e2e/step-definitions/sec-fetch-site.steps.ts b/e2e/step-definitions/sec-fetch-site.steps.ts index bbfddcd1..81e515ce 100644 --- a/e2e/step-definitions/sec-fetch-site.steps.ts +++ b/e2e/step-definitions/sec-fetch-site.steps.ts @@ -33,7 +33,8 @@ When( client_id: clientMetaUrl, redirect_uri: `${testEnv.demoUrl}/api/oauth/callback`, response_type: 'code', - scope: 'atproto transition:generic', + scope: + 'atproto include:org.hypercerts.authWrite include:app.certified.authWrite', state: 'test-state', code_challenge: 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM', code_challenge_method: 'S256', diff --git a/package.json b/package.json index 6220f103..5d117057 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ePDS", - "version": "0.6.5", + "version": "0.7.0", "private": true, "description": "ePDS — extended Personal Data Server for AT Protocol with passwordless OTP authentication", "license": "MIT", diff --git a/packages/auth-service/.env.example b/packages/auth-service/.env.example index dfb1cf6a..caa0d728 100644 --- a/packages/auth-service/.env.example +++ b/packages/auth-service/.env.example @@ -138,6 +138,14 @@ SMTP_PASS= SMTP_FROM=noreply@pds.example SMTP_FROM_NAME=ePDS +# Resend users only (optional). ePDS supports any SMTP provider and does not +# require Resend. To opt into Resend email event logs, register +# https:///webhooks/resend for email.sent, email.delivered, +# email.delivery_delayed, email.opened, email.bounced, email.failed, +# email.complained, email.suppressed, and email.scheduled, then set: +# RESEND_WEBHOOK_SECRET=whsec_... +# Account-wide Resend events are filtered to the exact SMTP_FROM address. + # Database path (separate from PDS account.sqlite) DB_LOCATION=/data/epds.sqlite diff --git a/packages/auth-service/package.json b/packages/auth-service/package.json index 35bcf641..dd0e07da 100644 --- a/packages/auth-service/package.json +++ b/packages/auth-service/package.json @@ -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", diff --git a/packages/auth-service/src/__tests__/email-template.test.ts b/packages/auth-service/src/__tests__/email-template.test.ts index b2894841..1b9139ca 100644 --- a/packages/auth-service/src/__tests__/email-template.test.ts +++ b/packages/auth-service/src/__tests__/email-template.test.ts @@ -13,6 +13,7 @@ import { EmailSender, _seedTemplateCacheForTest, _clearTemplateCacheForTest, + _loggerForTest, } from '../email/sender.js' import { formatOtpHtmlGrouped, @@ -267,4 +268,102 @@ describe('EmailSender', () => { ).resolves.toBeUndefined() }) }) + + // Regression cover for #183: the per-send completion line must carry + // the SMTP handoff duration and the provider's messageId / server + // response so app logs can be timed and joined to Resend delivery + // events. jsonTransport supplies a real messageId + response. + describe('per-send diagnostic logging', () => { + it('folds elapsedMs, messageId and smtpResponse into the success line', async () => { + const sender = makeSender() + const info = vi + .spyOn(_loggerForTest, 'info') + .mockImplementation(() => _loggerForTest) + + await sender.sendOtpCode({ + to: 'user@test.com', + code: '12345678', + clientAppName: 'Test App', + pdsName: 'Test PDS', + pdsDomain: 'pds.example', + }) + + expect(info).toHaveBeenCalledOnce() + const [fields, message] = info.mock.calls[0] as [ + Record, + string, + ] + expect(message).toBe('Sent sign-in OTP email') + expect(fields.email).toBe('user@test.com') + expect(fields.elapsedMs).toEqual(expect.any(Number)) + expect(fields.elapsedMs as number).toBeGreaterThanOrEqual(0) + expect(fields.messageId).toEqual(expect.any(String)) + // jsonTransport reports its serialized envelope as the response. + expect(fields).toHaveProperty('smtpResponse') + + info.mockRestore() + }) + + it('carries clientId alongside the diagnostic fields on the branded line', async () => { + const TRUSTED_ID = 'https://branded.app/client-metadata.json' + _seedClientMetadataCacheForTest(TRUSTED_ID, { + client_name: 'Branded App', + email_template_uri: 'https://branded.app/email-template.html', + logo_uri: 'https://branded.app/logo.png', + }) + _seedTemplateCacheForTest( + 'https://branded.app/email-template.html', + 'Your code is {{code}}', + ) + const sender = makeSender([TRUSTED_ID]) + const info = vi + .spyOn(_loggerForTest, 'info') + .mockImplementation(() => _loggerForTest) + + await sender.sendOtpCode({ + to: 'branded@test.com', + code: '99999999', + clientAppName: 'Fallback Name', + clientId: TRUSTED_ID, + pdsName: 'Test PDS', + pdsDomain: 'pds.example', + }) + + const [fields, message] = info.mock.calls[0] as [ + Record, + string, + ] + expect(message).toBe('Sent client-branded OTP email') + expect(fields.clientId).toBe(TRUSTED_ID) + expect(fields.elapsedMs).toEqual(expect.any(Number)) + expect(fields.messageId).toEqual(expect.any(String)) + + info.mockRestore() + }) + + it('stamps elapsedMs onto a failed send for the caller error log', async () => { + const sender = makeSender() + vi.spyOn(sender['transporter'], 'sendMail').mockRejectedValue( + new Error('SMTP handshake timed out'), + ) + + let caught: (Error & { elapsedMs?: number }) | undefined + try { + await sender.sendOtpCode({ + to: 'user@test.com', + code: '12345678', + clientAppName: 'Test App', + pdsName: 'Test PDS', + pdsDomain: 'pds.example', + }) + } catch (e) { + caught = e as Error & { elapsedMs?: number } + } + + expect(caught).toBeInstanceOf(Error) + expect(caught?.message).toBe('SMTP handshake timed out') + expect(caught?.elapsedMs).toEqual(expect.any(Number)) + expect(caught?.elapsedMs as number).toBeGreaterThanOrEqual(0) + }) + }) }) diff --git a/packages/auth-service/src/__tests__/rate-limit.test.ts b/packages/auth-service/src/__tests__/rate-limit.test.ts index 23533805..3ad4d64b 100644 --- a/packages/auth-service/src/__tests__/rate-limit.test.ts +++ b/packages/auth-service/src/__tests__/rate-limit.test.ts @@ -98,6 +98,29 @@ describe('requestRateLimit', () => { expect(next2).toBe(true) }) + it('keeps independently configured limiter namespaces separate', () => { + const ip = `namespace-${Date.now()}-${randomUUID()}` + const globalLimiter = requestRateLimit({ + windowMs: 60_000, + maxRequests: 1, + }) + const webhookLimiter = requestRateLimit({ + windowMs: 60_000, + maxRequests: 1, + keyPrefix: 'resend-webhook', + }) + + for (const limiter of [globalLimiter, webhookLimiter]) { + const req = makeReq(ip) + const res = makeRes() + let nextCalled = false + limiter(req as never, res as never, () => { + nextCalled = true + }) + expect(nextCalled).toBe(true) + } + }) + it('uses req.ip when available', () => { const limiter = requestRateLimit({ windowMs: 60000, maxRequests: 10 }) const req = { diff --git a/packages/auth-service/src/__tests__/resend-webhook.test.ts b/packages/auth-service/src/__tests__/resend-webhook.test.ts new file mode 100644 index 00000000..d66b118d --- /dev/null +++ b/packages/auth-service/src/__tests__/resend-webhook.test.ts @@ -0,0 +1,288 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import express from 'express' +import { Webhook } from 'svix' + +const { logDebug, logInfo, logWarn } = vi.hoisted(() => ({ + logDebug: vi.fn(), + logInfo: vi.fn(), + logWarn: vi.fn(), +})) + +vi.mock('@certified-app/shared', () => ({ + createLogger: () => ({ debug: logDebug, info: logInfo, warn: logWarn }), +})) + +import { createResendWebhookRouter } from '../routes/resend-webhook.js' + +const WEBHOOK_SECRET = `whsec_${Buffer.from('test-webhook-secret').toString( + 'base64', +)}` + +beforeEach(() => { + logDebug.mockClear() + logInfo.mockClear() + logWarn.mockClear() +}) + +function makeEvent(type = 'email.sent'): Record { + return { + type, + created_at: '2026-07-14T10:00:00.000Z', + data: { + email_id: 'resend-email-123', + to: ['person@example.com'], + from: 'ePDS ', + subject: '123456 — Your sign-in code', + }, + } +} + +async function postWebhook( + event: Record, + options: { + svixId?: string + validSignature?: boolean + includeHeaders?: boolean + otpLength?: number + otpCharset?: 'numeric' | 'alphanumeric' + } = {}, +): Promise<{ status: number; json: Record }> { + const app = express() + app.use( + createResendWebhookRouter( + WEBHOOK_SECRET, + 'login@example.org', + options.otpLength ?? 6, + options.otpCharset ?? 'numeric', + ), + ) + const server = app.listen(0) + + try { + server.unref() + const port = await new Promise((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 = { + '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, + } + } finally { + await new Promise((resolve) => { + server.close(() => { + resolve() + }) + }) + } +} + +describe('Resend webhook receiver', () => { + it('verifies, logs, and acknowledges a delivery event', async () => { + const result = await postWebhook(makeEvent('email.delivered')) + + expect(result).toEqual({ status: 200, json: { received: true } }) + expect(logInfo).toHaveBeenCalledWith( + { + provider: 'resend', + eventId: 'msg_test_123', + eventType: 'delivered', + occurredAt: '2026-07-14T10:00:00.000Z', + messageId: 'resend-email-123', + email: 'person@example.com', + subject: '[REDACTED] — Your sign-in code', + }, + 'Received email event', + ) + }) + + it.each([ + ['email.opened', 'opened'], + ['email.scheduled', 'scheduled'], + ])('normalizes %s events as %s', async (resendType, normalizedType) => { + const result = await postWebhook(makeEvent(resendType)) + + expect(result).toEqual({ status: 200, json: { received: true } }) + expect(logInfo).toHaveBeenCalledWith( + expect.objectContaining({ + provider: 'resend', + eventType: normalizedType, + messageId: 'resend-email-123', + email: 'person@example.com', + }), + 'Received email event', + ) + }) + + it.each([ + { + label: 'a grouped numeric code', + subject: '1234 5678 — Your sign-in code', + otpLength: 8, + otpCharset: 'numeric' as const, + expected: '[REDACTED] — Your sign-in code', + }, + { + label: 'an alphanumeric code', + subject: 'AB12CD — Your sign-in code', + otpLength: 6, + otpCharset: 'alphanumeric' as const, + expected: '[REDACTED] — Your sign-in code', + }, + { + label: 'a subject without a code', + subject: 'Verify your backup email', + otpLength: 6, + otpCharset: 'numeric' as const, + expected: 'Verify your backup email', + }, + ])( + 'sanitizes $label without hiding the rest of the subject', + async (test) => { + const event = makeEvent() + const data = event.data as Record + data.subject = test.subject + + await postWebhook(event, { + otpLength: test.otpLength, + otpCharset: test.otpCharset, + }) + + expect(logInfo.mock.calls[0]?.[0].subject).toBe(test.expected) + }, + ) + + it('logs retries with the same Svix ID for downstream deduplication', async () => { + await postWebhook(makeEvent(), { svixId: 'msg_retry' }) + await postWebhook(makeEvent(), { svixId: 'msg_retry' }) + + expect(logInfo).toHaveBeenCalledTimes(2) + expect(logInfo.mock.calls.map(([fields]) => fields.eventId)).toEqual([ + 'msg_retry', + 'msg_retry', + ]) + }) + + it('acknowledges account-wide events for other senders without logging them', async () => { + const event = makeEvent() + const data = event.data as Record + data.from = 'Another service ' + + const result = await postWebhook(event, { svixId: 'msg_other_sender' }) + + expect(result).toEqual({ + status: 200, + json: { received: true, ignored: true }, + }) + expect(logInfo).not.toHaveBeenCalled() + expect(logDebug).toHaveBeenCalledWith( + { provider: 'resend', eventId: 'msg_other_sender' }, + 'Ignored email webhook for another sender', + ) + }) + + it.each([ + ['email.delivery_delayed', 'delayed'], + ['email.complained', 'complained'], + ['email.suppressed', 'suppressed'], + ])('logs %s as %s at warning level', async (resendType, normalizedType) => { + await postWebhook(makeEvent(resendType)) + + expect(logWarn).toHaveBeenCalledWith( + expect.objectContaining({ + provider: 'resend', + eventId: 'msg_test_123', + eventType: normalizedType, + messageId: 'resend-email-123', + }), + 'Received email event', + ) + }) + + it('rejects an invalid signature before logging the payload', async () => { + const result = await postWebhook(makeEvent(), { validSignature: false }) + + expect(result.status).toBe(400) + expect(result.json).toEqual({ error: 'Invalid webhook signature' }) + expect(logInfo).not.toHaveBeenCalled() + }) + + 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(logInfo).not.toHaveBeenCalled() + }) + + it.each(['email.clicked', 'email.received'])( + 'acknowledges ignored %s events without logging their payload', + async (eventType) => { + const result = await postWebhook(makeEvent(eventType)) + + expect(result).toEqual({ + status: 200, + json: { received: true, ignored: true }, + }) + expect(logInfo).not.toHaveBeenCalled() + expect(logWarn).not.toHaveBeenCalled() + expect(logDebug).toHaveBeenCalledWith( + { + provider: 'resend', + eventId: 'msg_test_123', + eventType: eventType.slice('email.'.length), + }, + 'Ignored unsupported email webhook event', + ) + }, + ) + + it('rejects unknown signed event types', async () => { + const result = await postWebhook(makeEvent('email.unknown')) + + expect(result.status).toBe(400) + expect(result.json).toEqual({ error: 'Invalid webhook payload' }) + expect(logInfo).not.toHaveBeenCalled() + }) + + it('rejects an email event without a recipient email', async () => { + const event = makeEvent() + const data = event.data as Record + data.to = [] + + const result = await postWebhook(event) + + expect(result.status).toBe(400) + expect(result.json).toEqual({ error: 'Invalid webhook payload' }) + expect(logInfo).not.toHaveBeenCalled() + }) +}) diff --git a/packages/auth-service/src/better-auth.ts b/packages/auth-service/src/better-auth.ts index c5bd2a2b..81796123 100644 --- a/packages/auth-service/src/better-auth.ts +++ b/packages/auth-service/src/better-auth.ts @@ -362,9 +362,15 @@ export function createBetterAuth( isNewUser, }) .catch((err: unknown) => { - // Log and swallow — caller does not await this + // Log and swallow — caller does not await this. + // `EmailSender.timedSendMail` stamps the SMTP handoff + // duration onto the error so this single line carries it. + const elapsedMs = + err instanceof Error + ? (err as Error & { elapsedMs?: number }).elapsedMs + : undefined logger.error( - { err, email, isNewUser }, + { err, email, isNewUser, elapsedMs }, 'better-auth: failed to send OTP email', ) }) diff --git a/packages/auth-service/src/context.ts b/packages/auth-service/src/context.ts index 65f6d787..a31014fa 100644 --- a/packages/auth-service/src/context.ts +++ b/packages/auth-service/src/context.ts @@ -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 diff --git a/packages/auth-service/src/email/sender.ts b/packages/auth-service/src/email/sender.ts index 6705d4ac..8ddfe82e 100644 --- a/packages/auth-service/src/email/sender.ts +++ b/packages/auth-service/src/email/sender.ts @@ -1,6 +1,6 @@ import * as nodemailer from 'nodemailer' import { createLogger } from '@certified-app/shared' -import type { Transporter } from 'nodemailer' +import type { SendMailOptions, SentMessageInfo, Transporter } from 'nodemailer' import type { EmailConfig } from '@certified-app/shared' import { buildSignInCodeEmail, @@ -11,6 +11,11 @@ import { buildClientBrandedEmail } from './client-template.js' const logger = createLogger('auth:email') +// Exposed for tests that assert the structured fields on the per-send +// completion line (elapsedMs / messageId / smtpResponse). Production +// code must not import this. +export const _loggerForTest: ReturnType = logger + // Re-exports so existing tests that reach for the template cache still // resolve through sender.js. New code should import directly from // ./client-template.js. @@ -95,6 +100,47 @@ export class EmailSender { } } + /** + * Send one message, measuring how long the SMTP handoff takes and + * folding the elapsed time plus the provider's `messageId` / server + * `response` into a single structured completion line. `messageId` + * lets these app logs be joined to Resend's per-message delivery + * events (see #183 / #198) so provider-side latency can be told apart + * from users submitting stale codes. + * + * On failure the error is re-thrown after stamping it with + * `elapsedMs`, so the caller's existing error log can carry the + * duration without adding a second line per failure. + */ + private async timedSendMail( + mail: SendMailOptions, + message: string, + extra: Record = {}, + ): Promise { + const startedAt = performance.now() + try { + const info: SentMessageInfo = await this.transporter.sendMail(mail) + const elapsedMs = Math.round(performance.now() - startedAt) + logger.info( + { + email: mail.to, + ...extra, + elapsedMs, + messageId: info.messageId, + smtpResponse: info.response, + }, + message, + ) + } catch (err) { + const elapsedMs = Math.round(performance.now() - startedAt) + if (err instanceof Error) { + // Stamp the duration so the caller's error log carries it. + ;(err as Error & { elapsedMs?: number }).elapsedMs = elapsedMs + } + throw err + } + } + async sendOtpCode(opts: { to: string code: string @@ -125,16 +171,16 @@ export class EmailSender { trustedClients: this.trustedClients, }) if (branded) { - await this.transporter.sendMail({ - from: `"${branded.fromName}" <${this.config.from}>`, - to, - subject: branded.subject, - text: branded.text, - html: branded.html, - }) - logger.info( - { to, clientId: opts.clientId }, + await this.timedSendMail( + { + from: `"${branded.fromName}" <${this.config.from}>`, + to, + subject: branded.subject, + text: branded.text, + html: branded.html, + }, 'Sent client-branded OTP email', + { clientId: opts.clientId }, ) return } @@ -158,13 +204,16 @@ export class EmailSender { const { to, ...rest } = opts const { subject, text, html } = buildSignInCodeEmail(rest) - await this.transporter.sendMail({ - from: `"${this.config.fromName}" <${this.config.from}>`, - to, - subject, - text, - html, - }) + await this.timedSendMail( + { + from: `"${this.config.fromName}" <${this.config.from}>`, + to, + subject, + text, + html, + }, + 'Sent sign-in OTP email', + ) } private async sendWelcomeCode(opts: { @@ -176,13 +225,16 @@ export class EmailSender { const { to, ...rest } = opts const { subject, text, html } = buildWelcomeCodeEmail(rest) - await this.transporter.sendMail({ - from: `"${this.config.fromName}" <${this.config.from}>`, - to, - subject, - text, - html, - }) + await this.timedSendMail( + { + from: `"${this.config.fromName}" <${this.config.from}>`, + to, + subject, + text, + html, + }, + 'Sent welcome OTP email', + ) } async sendBackupEmailVerification(opts: { @@ -194,12 +246,15 @@ export class EmailSender { const { to, ...rest } = opts const { subject, text, html } = buildBackupEmailVerificationEmail(rest) - await this.transporter.sendMail({ - from: `"${this.config.fromName}" <${this.config.from}>`, - to, - subject, - text, - html, - }) + await this.timedSendMail( + { + from: `"${this.config.fromName}" <${this.config.from}>`, + to, + subject, + text, + html, + }, + 'Sent backup email verification', + ) } } diff --git a/packages/auth-service/src/index.ts b/packages/auth-service/src/index.ts index fc59cc2c..12a3c6cf 100644 --- a/packages/auth-service/src/index.ts +++ b/packages/auth-service/src/index.ts @@ -18,6 +18,10 @@ 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, + RESEND_WEBHOOK_PATH, +} 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' @@ -36,6 +40,7 @@ export function createAuthService(config: AuthServiceConfig): { } { const ctx = new AuthServiceContext(config) const app = express() + app.set('trust proxy', 1) // Mount better-auth BEFORE express.json() so it can parse its own request bodies. // All better-auth endpoints live under /api/auth/*. @@ -47,8 +52,29 @@ 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( + RESEND_WEBHOOK_PATH, + requestRateLimit({ + windowMs: 60_000, + maxRequests: 300, + keyPrefix: 'resend-webhook', + }), + ) + app.use( + createResendWebhookRouter( + config.resendWebhookSecret, + config.email.from, + config.otpLength, + config.otpCharset, + ), + ) + } + // Middleware - app.set('trust proxy', 1) app.use(express.urlencoded({ extended: true })) app.use(express.json()) app.use(cookieParser()) @@ -158,6 +184,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()) diff --git a/packages/auth-service/src/middleware/rate-limit.ts b/packages/auth-service/src/middleware/rate-limit.ts index 438d7cdc..f8ed2d99 100644 --- a/packages/auth-service/src/middleware/rate-limit.ts +++ b/packages/auth-service/src/middleware/rate-limit.ts @@ -26,6 +26,7 @@ setInterval( export function requestRateLimit(opts: { windowMs: number maxRequests: number + keyPrefix?: string }) { const disabled = process.env.EPDS_DISABLE_RATE_LIMIT === 'true' return (req: Request, res: Response, next: NextFunction): void => { @@ -34,12 +35,13 @@ export function requestRateLimit(opts: { return } const ip = req.ip || req.socket.remoteAddress || 'unknown' + const key = `${opts.keyPrefix ?? 'global'}:${ip}` const now = Date.now() - let entry = requestCounts.get(ip) + let entry = requestCounts.get(key) if (!entry || entry.resetAt < now) { entry = { count: 0, resetAt: now + opts.windowMs } - requestCounts.set(ip, entry) + requestCounts.set(key, entry) } entry.count++ diff --git a/packages/auth-service/src/routes/resend-webhook.ts b/packages/auth-service/src/routes/resend-webhook.ts new file mode 100644 index 00000000..dffc42e3 --- /dev/null +++ b/packages/auth-service/src/routes/resend-webhook.ts @@ -0,0 +1,297 @@ +/** + * Resend email webhook receiver. + * + * The route consumes the untouched request body, verifies Resend's Svix + * signature before inspecting the payload, accepts only email events used by + * ePDS, and emits one structured log entry per event. Resend may retry the same + * source ID (logged as `eventId`) and may deliver an email's events out of + * order, so log consumers must deduplicate and order events when calculating + * latency. + */ +import express, { Router } from 'express' +import addressparser from 'nodemailer/lib/addressparser/index.js' +import { Webhook } from 'svix' +import { createLogger } from '@certified-app/shared' + +const logger = createLogger('auth:email-webhook') + +export const RESEND_WEBHOOK_PATH = '/webhooks/resend' + +const LOGGED_RESEND_EVENT_TYPES = [ + 'email.sent', + 'email.delivered', + 'email.delivery_delayed', + 'email.opened', + 'email.bounced', + 'email.failed', + 'email.complained', + 'email.suppressed', + 'email.scheduled', +] as const + +const IGNORED_RESEND_EVENT_TYPES = ['email.clicked', 'email.received'] as const +const RESEND_EVENT_TYPES = [ + ...LOGGED_RESEND_EVENT_TYPES, + ...IGNORED_RESEND_EVENT_TYPES, +] as const + +const WARNING_RESEND_EVENT_TYPES = [ + 'email.delivery_delayed', + 'email.complained', + 'email.suppressed', +] as const + +type LoggedResendEventType = (typeof LOGGED_RESEND_EVENT_TYPES)[number] +type ResendEventType = (typeof RESEND_EVENT_TYPES)[number] +type EmailEventType = + | 'sent' + | 'delivered' + | 'delayed' + | 'opened' + | 'bounced' + | 'failed' + | 'complained' + | 'suppressed' + | 'scheduled' +type OtpCharset = 'numeric' | 'alphanumeric' + +const NORMALIZED_EVENT_TYPES: Record = { + 'email.sent': 'sent', + 'email.delivered': 'delivered', + 'email.delivery_delayed': 'delayed', + 'email.opened': 'opened', + 'email.bounced': 'bounced', + 'email.failed': 'failed', + 'email.complained': 'complained', + 'email.suppressed': 'suppressed', + 'email.scheduled': 'scheduled', +} + +interface ResendEvent { + type: ResendEventType + created_at: string + data: { + email_id: string + to: [string, ...string[]] + from: string + subject: string + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function parseSingleEmailAddress(value: string): string | null { + try { + const addresses = addressparser(value, { flatten: true }) + if (addresses.length !== 1) return null + const address = addresses[0]?.address.trim().toLowerCase() + return address || null + } catch (err) { + logger.warn({ err }, 'Failed to parse email sender address') + return null + } +} + +function isResendEvent(value: unknown): value is ResendEvent { + if (!isRecord(value) || !isRecord(value.data)) return false + + const data = value.data + return ( + typeof value.type === 'string' && + (RESEND_EVENT_TYPES as readonly string[]).includes(value.type) && + typeof value.created_at === 'string' && + Number.isFinite(Date.parse(value.created_at)) && + typeof data.email_id === 'string' && + Array.isArray(data.to) && + data.to.length > 0 && + data.to.every((recipient) => typeof recipient === 'string') && + typeof data.from === 'string' && + typeof data.subject === 'string' + ) +} + +interface SvixHeaders { + 'svix-id': string + 'svix-timestamp': string + 'svix-signature': string +} + +type VerificationResult = + | { ok: true; headers: SvixHeaders; event: ResendEvent } + | { + ok: false + error: + | 'Invalid webhook request' + | 'Invalid webhook signature' + | 'Invalid webhook payload' + } + +function getSvixHeaders(req: express.Request): SvixHeaders | null { + const id = req.get('svix-id') + const timestamp = req.get('svix-timestamp') + const signature = req.get('svix-signature') + if (!id || !timestamp || !signature) return null + return { + 'svix-id': id, + 'svix-timestamp': timestamp, + 'svix-signature': signature, + } +} + +function verifyResendWebhook( + req: express.Request, + verifier: Webhook, +): VerificationResult { + const headers = getSvixHeaders(req) + if (!headers || !Buffer.isBuffer(req.body)) { + return { ok: false, error: 'Invalid webhook request' } + } + + let payload: unknown + try { + payload = verifier.verify(req.body, headers) + } catch (err) { + logger.warn( + { err, provider: 'resend', eventId: headers['svix-id'] }, + 'Rejected email webhook with invalid signature', + ) + return { ok: false, error: 'Invalid webhook signature' } + } + + if (!isResendEvent(payload)) { + logger.warn( + { provider: 'resend', eventId: headers['svix-id'] }, + 'Rejected invalid email webhook payload', + ) + return { ok: false, error: 'Invalid webhook payload' } + } + return { ok: true, headers, event: payload } +} + +const NUMERIC_OTP_CANDIDATE = + /(^|\D)((?:\d{3,4}(?: \d{3,4}){1,3}|\d{4,12}))(?!\d)/g +const ALPHANUMERIC_OTP_CANDIDATE = + /(^|[^A-Za-z0-9])((?:[A-Z0-9]{3,4}(?: [A-Z0-9]{3,4}){1,3}|[A-Z0-9]{4,12}))(?![A-Za-z0-9])/g + +function otpGroupSize(length: number): number | null { + if (length < 8) return null + if (length % 4 === 0) return 4 + if (length % 3 === 0) return 3 + return null +} + +function isOtpCandidate(candidate: string, otpLength: number): boolean { + const raw = candidate.replaceAll(' ', '') + if (raw.length !== otpLength) return false + if (!candidate.includes(' ')) return true + + const groupSize = otpGroupSize(otpLength) + if (!groupSize) return false + const groups = candidate.split(' ') + return ( + groups.length === otpLength / groupSize && + groups.every((group) => group.length === groupSize) + ) +} + +function redactOtpFromSubject( + subject: string, + otpLength: number, + otpCharset: OtpCharset, +): string { + const pattern = + otpCharset === 'numeric' + ? NUMERIC_OTP_CANDIDATE + : ALPHANUMERIC_OTP_CANDIDATE + return subject.replace( + pattern, + (match: string, prefix: string, candidate: string) => + isOtpCandidate(candidate, otpLength) ? `${prefix}[REDACTED]` : match, + ) +} + +function isLoggedResendEvent( + event: ResendEvent, +): event is ResendEvent & { type: LoggedResendEventType } { + return (LOGGED_RESEND_EVENT_TYPES as readonly string[]).includes(event.type) +} + +function logResendEvent( + event: ResendEvent & { type: LoggedResendEventType }, + svixId: string, + otpLength: number, + otpCharset: OtpCharset, +): void { + const fields = { + provider: 'resend', + eventId: svixId, + eventType: NORMALIZED_EVENT_TYPES[event.type], + occurredAt: event.created_at, + messageId: event.data.email_id, + email: event.data.to[0], + subject: redactOtpFromSubject(event.data.subject, otpLength, otpCharset), + } + const message = 'Received email event' + if ((WARNING_RESEND_EVENT_TYPES as readonly string[]).includes(event.type)) { + logger.warn(fields, message) + } else { + logger.info(fields, message) + } +} + +export function createResendWebhookRouter( + webhookSecret: string, + expectedFrom: string, + otpLength: number, + otpCharset: OtpCharset, +): Router { + const router = Router() + const verifier = new Webhook(webhookSecret) + const expectedFromAddress = parseSingleEmailAddress(expectedFrom) + if (!expectedFromAddress) { + throw new Error('RESEND_WEBHOOK_SECRET requires a valid SMTP_FROM address') + } + + router.post( + RESEND_WEBHOOK_PATH, + express.raw({ type: 'application/json', limit: '64kb' }), + (req, res) => { + const result = verifyResendWebhook(req, verifier) + if (!result.ok) { + res.status(400).json({ error: result.error }) + return + } + + const eventId = result.headers['svix-id'] + if (!isLoggedResendEvent(result.event)) { + logger.debug( + { + provider: 'resend', + eventId, + eventType: result.event.type.slice('email.'.length), + }, + 'Ignored unsupported email webhook event', + ) + res.status(200).json({ received: true, ignored: true }) + return + } + + const eventFromAddress = parseSingleEmailAddress(result.event.data.from) + if (eventFromAddress !== expectedFromAddress) { + logger.debug( + { provider: 'resend', eventId }, + 'Ignored email webhook for another sender', + ) + res.status(200).json({ received: true, ignored: true }) + return + } + + logResendEvent(result.event, eventId, otpLength, otpCharset) + res.status(200).json({ received: true }) + }, + ) + + return router +} diff --git a/packages/demo/src/app/api/oauth/login/route.ts b/packages/demo/src/app/api/oauth/login/route.ts index c54583cc..bf4e4636 100644 --- a/packages/demo/src/app/api/oauth/login/route.ts +++ b/packages/demo/src/app/api/oauth/login/route.ts @@ -153,7 +153,8 @@ export async function GET(request: Request) { client_id: clientId, redirect_uri: redirectUri, response_type: 'code', - scope: 'atproto transition:generic', + scope: + 'atproto include:org.hypercerts.authWrite include:app.certified.authWrite', state, code_challenge: codeChallenge, code_challenge_method: 'S256', diff --git a/packages/demo/src/app/client-metadata.json/route.ts b/packages/demo/src/app/client-metadata.json/route.ts index 0651af16..a5c0b5cf 100644 --- a/packages/demo/src/app/client-metadata.json/route.ts +++ b/packages/demo/src/app/client-metadata.json/route.ts @@ -50,7 +50,8 @@ export async function GET() { client_uri: baseUrl, logo_uri: `${baseUrl}/certified-logo.png`, redirect_uris: [`${baseUrl}/api/oauth/callback`], - scope: 'atproto transition:generic', + scope: + 'atproto include:org.hypercerts.authWrite include:app.certified.authWrite', grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], ...(isConfidential diff --git a/packages/shared/src/__tests__/db-extended.test.ts b/packages/shared/src/__tests__/db-extended.test.ts index 7de43e27..4084444f 100644 --- a/packages/shared/src/__tests__/db-extended.test.ts +++ b/packages/shared/src/__tests__/db-extended.test.ts @@ -261,8 +261,9 @@ describe('Database initialization', () => { fs.rmSync(path.dirname(path.dirname(nestedPath)), { recursive: true, }) - // eslint-disable-next-line no-empty - } catch {} + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err + } }) it('runs migrations idempotently on second open', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b8edc4c..a0d50948 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,6 +84,9 @@ importers: nodemailer: specifier: ^6.9.8 version: 6.10.1 + svix: + specifier: ^1.96.1 + version: 1.96.1 devDependencies: '@types/better-sqlite3': specifier: ^7.6.8 @@ -1551,6 +1554,9 @@ packages: resolution: {integrity: sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==} engines: {node: '>=18.0.0'} + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -2629,6 +2635,9 @@ packages: resolution: {integrity: sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==} engines: {node: '>=6'} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fast-xml-parser@5.3.6: resolution: {integrity: sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA==} hasBin: true @@ -3944,6 +3953,9 @@ packages: standard-as-callback@2.1.0: resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + standardwebhooks@1.0.0: + resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -4033,6 +4045,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + svix@1.96.1: + resolution: {integrity: sha512-l+GyPS6gjL0okiXplLazEY2GbBsv1jZ4UIwtjp553YkDDv635xMKbM5sABpIdiWy1BYxgyV8M6tHeTwY0gyXlQ==} + tagged-tag@1.0.0: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} @@ -6455,6 +6470,8 @@ snapshots: dependencies: tslib: 2.8.1 + '@stablelib/base64@1.0.1': {} + '@standard-schema/spec@1.1.0': {} '@swc/helpers@0.5.15': @@ -7655,6 +7672,8 @@ snapshots: fast-redact@3.5.0: {} + fast-sha256@1.3.0: {} + fast-xml-parser@5.3.6: dependencies: strnum: 2.1.2 @@ -9052,6 +9071,11 @@ snapshots: standard-as-callback@2.1.0: {} + standardwebhooks@1.0.0: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + statuses@2.0.2: {} std-env@4.0.0: {} @@ -9141,6 +9165,10 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + svix@1.96.1: + dependencies: + standardwebhooks: 1.0.0 + tagged-tag@1.0.0: {} tar-fs@2.1.4: diff --git a/scripts/setup.sh b/scripts/setup.sh index 6518c79b..e794f0e9 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -53,7 +53,7 @@ generate_secrets_in_file() { } # Copy shared vars from the top-level .env into a per-package .env. -# Only sets vars that already have an uncommented line in the target file, +# Only sets vars already present in the target or documented in its example, # so packages don't end up with vars they don't use. inject_shared_vars() { local target="$1" @@ -63,12 +63,13 @@ inject_shared_vars() { PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX \ EPDS_INVITE_CODE PDS_INTERNAL_URL \ SMTP_HOST SMTP_PORT SMTP_USER SMTP_PASS SMTP_FROM SMTP_FROM_NAME PDS_EMAIL_FROM_ADDRESS \ + RESEND_WEBHOOK_SECRET \ PDS_TERMS_OF_SERVICE_URL PDS_PRIVACY_POLICY_URL PDS_LEGAL_ENTITY_NAME; do - # Skip if the var isn't in the target AND isn't in the package's .env.example. - # This avoids injecting vars a package doesn't use, while still handling - # .env files created from an older .env.example that lacked the var. - if ! grep -qE "^${var}=" "$target" 2>/dev/null \ - && ! grep -qE "^${var}=" "$example" 2>/dev/null; then + # Optional variables may be commented out in examples. Recognising those + # comments lets an explicitly configured top-level value propagate while + # leaving the option absent for everyone else. + if ! grep -qE "^(# ?)?${var}=" "$target" 2>/dev/null \ + && ! grep -qE "^(# ?)?${var}=" "$example" 2>/dev/null; then continue fi local val @@ -166,20 +167,20 @@ prompt_hostname() { set_env_var PDS_HOSTNAME "$pds_hostname" .env set_env_var PDS_PUBLIC_URL "$pds_public_url" .env set_env_var AUTH_HOSTNAME "$auth_hostname" .env - set_env_var EPDS_LINK_BASE_URL "${proto}://${auth_hostname}/auth/verify" .env + set_env_var EPDS_LINK_BASE_URL "${proto}://${auth_hostname}/auth/verify" .env # NOSONAR — HTTP is selected only for localhost development. # Set PDS_INTERNAL_URL for multi-service deployments (auth-service → pds-core). # Docker: http://core:3000; Railway: http://.railway.internal:3000 # Not needed for localhost (both services on same host). if [ "$pds_hostname" != "localhost" ] && [[ "$pds_hostname" != *.localhost ]]; then - set_env_var PDS_INTERNAL_URL "http://core:3000" .env - echo " Set PDS_INTERNAL_URL=http://core:3000" + set_env_var PDS_INTERNAL_URL "http://core:3000" .env # NOSONAR — Docker's private network does not need TLS. + echo " Set PDS_INTERNAL_URL=http://core:3000" # NOSONAR — Docker's private network does not need TLS. fi echo " Set PDS_HOSTNAME=${pds_hostname}" echo " Set PDS_PUBLIC_URL=${pds_public_url}" echo " Set AUTH_HOSTNAME=${auth_hostname}" - echo " Set EPDS_LINK_BASE_URL=${proto}://${auth_hostname}/auth/verify" + echo " Set EPDS_LINK_BASE_URL=${proto}://${auth_hostname}/auth/verify" # NOSONAR — HTTP is selected only for localhost development. } # Ask for SMTP credentials. Sets discrete vars in .env (for auth-service) and @@ -484,8 +485,8 @@ print_next_steps() { echo " grep -v '^\s*#' packages/demo/.env | grep -v '^\s*$'" echo "" echo " IMPORTANT: For Railway, change PDS_INTERNAL_URL in auth-service from" - echo " the Docker value (http://core:3000) to the Railway internal URL:" - echo " http://.railway.internal:3000" + echo " the Docker value (http://core:3000) to the Railway internal URL:" # NOSONAR — Docker's private network does not need TLS. + echo " http://.railway.internal:3000" # NOSONAR — Railway's private network does not need TLS. echo " The auth service will fail to start without a correct PDS_INTERNAL_URL." echo "" echo " IMPORTANT: EPDS_CLIENT_PRIVATE_JWK must be DIFFERENT per demo service." diff --git a/vitest.config.ts b/vitest.config.ts index 1fcd4eac..954c8956 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -23,10 +23,10 @@ export default defineConfig({ // Ratchet thresholds — update these whenever coverage increases. // See AGENTS.md for the ratcheting policy. thresholds: { - statements: 57, - branches: 56, - functions: 70, - lines: 56, + statements: 58, + branches: 57, + functions: 71, + lines: 57, }, }, },