From fb548a15bc882829f6fcba8b0347f66bc7d90912 Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Mon, 19 Oct 2026 11:29:17 +0200 Subject: [PATCH 1/4] feat(auth): sign in to Studio with an emailed code Studio sign-in offered only Google, GitHub and Microsoft. A six-digit code sent by email adds a way in with no password and no new account. Each send needs a Turnstile pass, skips throwaway domains, and counts against per-inbox and per-network caps in the shared limiter. The feature stays off until the SMTP settings, AUTH_EMAIL_FROM and both Turnstile keys are set. Provider linking now needs the provider's verified flag, and /login redirects only to paths on this site. --- .env.example | 20 +- db/schema.ts | 7 +- db/test/limit-counter.test.ts | 38 ++- docs/analytics.md | 2 +- docs/development.md | 4 +- docs/feedback.md | 14 +- package.json | 3 +- pnpm-lock.yaml | 8 + src/app/api/auth/[...all]/route.ts | 9 +- .../atoms/Button/Button.module.scss | 14 +- src/components/atoms/Turnstile/Turnstile.tsx | 85 +++++++ .../organisms/LoginScreen/AuthPage.tsx | 19 +- .../organisms/LoginScreen/EmailSignIn.tsx | 230 ++++++++++++++++++ .../LoginScreen/LoginScreen.module.scss | 116 ++++++++- .../organisms/LoginScreen/LoginScreen.tsx | 69 +++++- src/lib/auth/auth.ts | 74 ++++-- src/lib/auth/client.ts | 10 +- src/lib/auth/clientIp.ts | 56 +++++ src/lib/auth/codeLimit.ts | 88 +++++++ src/lib/auth/emailSignIn.ts | 96 ++++++++ src/lib/auth/emailSignInOptions.ts | 172 +++++++++++++ src/lib/auth/nextPath.ts | 22 ++ src/lib/auth/test/clientIp.test.ts | 79 ++++++ src/lib/auth/test/codeLimit.test.ts | 61 +++++ src/lib/auth/test/emailSignIn.test.ts | 54 ++++ src/lib/auth/test/emailSignInOptions.test.ts | 213 ++++++++++++++++ src/lib/auth/test/nextPath.test.ts | 28 +++ src/lib/feedback/mailer.ts | 19 +- src/lib/mail/smtp.ts | 37 +++ src/lib/mail/test/smtp.test.ts | 69 ++++++ src/lib/proxy/limitCounter.ts | 5 +- 31 files changed, 1645 insertions(+), 76 deletions(-) create mode 100644 src/components/atoms/Turnstile/Turnstile.tsx create mode 100644 src/components/organisms/LoginScreen/EmailSignIn.tsx create mode 100644 src/lib/auth/clientIp.ts create mode 100644 src/lib/auth/codeLimit.ts create mode 100644 src/lib/auth/emailSignIn.ts create mode 100644 src/lib/auth/emailSignInOptions.ts create mode 100644 src/lib/auth/nextPath.ts create mode 100644 src/lib/auth/test/clientIp.test.ts create mode 100644 src/lib/auth/test/codeLimit.test.ts create mode 100644 src/lib/auth/test/emailSignIn.test.ts create mode 100644 src/lib/auth/test/emailSignInOptions.test.ts create mode 100644 src/lib/auth/test/nextPath.test.ts create mode 100644 src/lib/mail/smtp.ts create mode 100644 src/lib/mail/test/smtp.test.ts diff --git a/.env.example b/.env.example index 4ac96ef..ee3829e 100644 --- a/.env.example +++ b/.env.example @@ -45,10 +45,9 @@ DATABASE_URL=postgresql://website_runtime:website_runtime_dev_password@localhost BETTER_AUTH_SECRET= BETTER_AUTH_URL=http://localhost:3000 -# Sign-in providers (Better Auth social sign-in — the only way to get an -# account; there are no password accounts). Each pair is optional: a -# provider without keys simply isn't offered on /login. Callback URL to -# register with every provider: +# Sign-in providers (Better Auth social sign-in). There are no password +# accounts. Each pair is optional: a provider without keys isn't offered on +# /login. Callback URL to register with every provider: # /api/auth/callback/ GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= @@ -57,12 +56,23 @@ GITHUB_CLIENT_SECRET= MICROSOFT_CLIENT_ID= MICROSOFT_CLIENT_SECRET= +# Email sign-in: /login emails a six-digit code (src/lib/auth/emailSignIn.ts). +# On only when these three and SMTP_HOST below are all set, so no code is +# sent without the Turnstile bot check. AUTH_EMAIL_FROM is the From header, +# e.g. DFO Studio . Turnstile keys come from +# the Cloudflare dashboard; locally, Cloudflare's always-pass test keys work: +# site 1x00000000000000000000AA, secret 1x0000000000000000000000000000000AA. +AUTH_EMAIL_FROM= +TURNSTILE_SITE_KEY= +TURNSTILE_SECRET_KEY= + # Feedback form (POST /api/feedback, docs/feedback.md). Submissions are # stored via DATABASE_URL above; each sink below is optional and off when # unset. The Slack webhook posts each submission to a channel (deployed: # mounted from Secret Manager by dfo-infra; values that aren't a # hooks.slack.com URL mean off). The SMTP block emails submissions to -# FEEDBACK_TO; unused until an email decision exists. +# FEEDBACK_TO; email sign-in sends its codes through it too. The connection +# must use TLS 1.2 or later, over STARTTLS (port 587) unless SMTP_SECURE=true. FEEDBACK_SLACK_WEBHOOK_URL= SMTP_HOST= SMTP_PORT= diff --git a/db/schema.ts b/db/schema.ts index 27530c1..a828881 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -223,9 +223,10 @@ export type EventRow = typeof event.$inferSelect; /** * One counter per limiter key (src/lib/proxy/limitCounter): the per-address - * limits on /api and /mcp and the Studio's daily prompt allowances. `key` - * names the allowance and the caller (`api:203.0.113.7`, - * `studio-user:`); `count` is the hits in the window that started + * limits on /api and /mcp, the Studio's daily prompt allowances and the + * hourly caps on sign-in codes sent. `key` names the allowance and the + * caller (`api:203.0.113.7`, `studio-user:`, + * `sign-in-code:`); `count` is the hits in the window that started * at `windowStart`. The next hit after the window ends starts the row over, * and rows idle for two days are deleted. */ diff --git a/db/test/limit-counter.test.ts b/db/test/limit-counter.test.ts index a67ee34..dffaddf 100644 --- a/db/test/limit-counter.test.ts +++ b/db/test/limit-counter.test.ts @@ -2,6 +2,7 @@ import path from 'node:path'; import assert from 'node:assert/strict'; import test from 'node:test'; import { drizzle } from 'drizzle-orm/node-postgres'; +import { CODE_LIMITS, codeLimitKeys, takeCodeAllowance } from '../../src/lib/auth/codeLimit.ts'; import { countHit, pruneCounters } from '../../src/lib/proxy/limitCounter.ts'; import { refundPrompt, spendPrompt } from '../../src/lib/studio/spendPrompt.ts'; import { GUEST_PROMPTS, USER_DAILY_PROMPTS } from '../../src/lib/studio/promptAllowance.ts'; @@ -14,7 +15,8 @@ const DAY_S = 86_400; /** * The shared limiter (src/lib/proxy/limitCounter) and the Studio's prompt - * allowances on top of it (src/lib/studio/spendPrompt), run as the runtime + * allowances and sign-in code caps on top of it (src/lib/studio/spendPrompt, + * src/lib/auth/codeLimit), run as the runtime * role on a migrated database, so migration 0017's table and the default * grants are exercised too. */ @@ -187,4 +189,38 @@ test('limit counters and Studio prompt allowances', async (t) => { assert.ok(refusal!.retry_after_seconds! > 0 && refusal!.retry_after_seconds! <= DAY_S); assert.equal(await spendPrompt(db, { userId: 'user-b' }), null); }); + + await t.test( + 'a sign-in code past a cap is refused, and the refusal charges no key', + async () => { + const send = (email: string, ip: string) => + takeCodeAllowance(db, codeLimitKeys('secret', email, ip)); + + for (let i = 0; i < CODE_LIMITS.address; i++) { + assert.deepEqual(await send('ana@one.org', '203.0.113.40'), { ok: true }); + } + + const refused = await send('ana@one.org', '203.0.113.40'); + + assert.equal(refused.ok, false); + assert.ok( + !refused.ok && + refused.retryAfterS > 0 && + refused.retryAfterS <= CODE_LIMITS.windowS, + ); + + // Use up one network's cap on other inboxes, then aim it at bea. + for (let i = 0; i < CODE_LIMITS.network; i++) { + await send(`user${i}@one.org`, '203.0.113.41'); + } + for (let i = 0; i < CODE_LIMITS.address + 1; i++) { + assert.equal((await send('bea@one.org', '203.0.113.41')).ok, false); + } + + // Those refusals left bea's inbox uncharged. + for (let i = 0; i < CODE_LIMITS.address; i++) { + assert.deepEqual(await send('bea@one.org', '203.0.113.42'), { ok: true }); + } + }, + ); }); diff --git a/docs/analytics.md b/docs/analytics.md index 09d0900..468f830 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -16,7 +16,7 @@ The browser stores `visitor_id` only after the visitor accepts "DFO usage statis | `studio.prompt` | a prompt sent in the Studio | `turn`, `intent` (`new` / `follow_up` / `edit`), `length`, `guest` | | `studio.card` | a card the harness put on the canvas | `kind`, `turn`, `source`, `indicators` (DCIDs), `years`, `unit` (in words with its magnitude and price basis, `US Dollar, millions, constant 2023 prices`; a composed unit as its numerator and denominator either side of "per", each part's magnitude and price basis in brackets beside it, `US Dollar per Total population` or `Euro (constant 2020 prices) per Total population`; a percentage as `Percent`; null when the card states none), `guest` | | `studio.session_end` | New Chat, or the page is left | `turns`, `cards`, `pins`, `guest` | -| `auth.signin` | a provider callback ended in a session | `provider` | +| `auth.signin` | a provider callback or an email code ended in a session | `provider` (`email` for a code) | | `guest.wall_hit` | a guest used up the free prompts | `prompts` | | `feedback.submit` | a feedback submission | `kind` (`chat` / `widget`) | | `embed.view` | an `/embed/` page rendered | `shareId`, `kind` (`card` / `canvas` / `conversation`), `host` (the hostname of the page the iframe sits on, else null) | diff --git a/docs/development.md b/docs/development.md index afcafbf..b04db80 100644 --- a/docs/development.md +++ b/docs/development.md @@ -17,7 +17,9 @@ Open [http://localhost:3000](http://localhost:3000). Copy `.env.example` to `.env.local`. The homepage and the data search run without anything set; the Studio needs `STUDIO_TOKEN`, and accounts need -`DATABASE_URL` and `BETTER_AUTH_SECRET`. To open the dev server from a +`DATABASE_URL` and `BETTER_AUTH_SECRET`. Email sign-in also needs the SMTP +settings, `AUTH_EMAIL_FROM` and the two Turnstile keys (`.env.example` has +Cloudflare's test keys for local use). To open the dev server from a phone/tablet on the same network, put the machine's LAN IP in `DEV_ORIGINS`. diff --git a/docs/feedback.md b/docs/feedback.md index 99eb552..f9de6c5 100644 --- a/docs/feedback.md +++ b/docs/feedback.md @@ -29,13 +29,13 @@ A submission with a filled honeypot gets a fake 200 so bots learn nothing. A sin ## Configuration -| Variable | Sink | Meaning | -|---|---|---| -| `DATABASE_URL` / `WEBSITE_DB_*` | storage | The shared pool from `src/lib/db`; nothing feedback-specific, storage is on wherever the app has a database | -| `FEEDBACK_SLACK_WEBHOOK_URL` | slack | Slack incoming webhook; deployed it is mounted from Secret Manager by dfo-infra, whose placeholder value `disabled` (or anything that isn't a URL) means off | -| `SMTP_HOST` / `SMTP_PORT` / `SMTP_SECURE` | email | SMTP endpoint; `SMTP_HOST` unset turns email off | -| `SMTP_USER` / `SMTP_PASS` | email | optional SMTP auth | -| `FEEDBACK_FROM` / `FEEDBACK_TO` | email | sender identity and destination inbox | +| Variable | Sink | Meaning | +| ----------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `DATABASE_URL` / `WEBSITE_DB_*` | storage | The shared pool from `src/lib/db`; nothing feedback-specific, storage is on wherever the app has a database | +| `FEEDBACK_SLACK_WEBHOOK_URL` | slack | Slack incoming webhook; deployed it is mounted from Secret Manager by dfo-infra, whose placeholder value `disabled` (or anything that isn't a URL) means off | +| `SMTP_HOST` / `SMTP_PORT` / `SMTP_SECURE` | email | SMTP endpoint, shared with email sign-in (`src/lib/mail/smtp.ts`). The connection must use TLS 1.2 or later. `SMTP_HOST` unset turns email off | +| `SMTP_USER` / `SMTP_PASS` | email | optional SMTP auth | +| `FEEDBACK_FROM` / `FEEDBACK_TO` | email | sender identity and destination inbox | Rotating the Slack webhook (leak, channel change) is a Secret Manager version plus a redeploy, no code change; see the dfo-infra website stack README. diff --git a/package.json b/package.json index dc1b5ed..7b4ed50 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "lint:css": "stylelint \"src/**/*.scss\"", "lint:css:fix": "stylelint \"src/**/*.scss\" --fix", "typecheck": "tsc --noEmit", - "test": "node --test db/test/*.test.ts src/lib/search/test/*.test.ts src/lib/studio/test/*.test.ts src/lib/chat/test/*.test.ts src/lib/proxy/test/*.test.ts src/lib/sources/test/*.test.ts src/lib/test/*.test.ts src/lib/docs/test/*.test.ts src/lib/graph/test/*.test.ts", + "test": "node --test db/test/*.test.ts src/lib/search/test/*.test.ts src/lib/studio/test/*.test.ts src/lib/chat/test/*.test.ts src/lib/proxy/test/*.test.ts src/lib/auth/test/*.test.ts src/lib/mail/test/*.test.ts src/lib/sources/test/*.test.ts src/lib/test/*.test.ts src/lib/docs/test/*.test.ts src/lib/graph/test/*.test.ts", "graph:types": "node scripts/graph-wire.mts types", "graph:schema": "node scripts/graph-wire.mts schema", "format": "prettier --write \"src/**/*.{ts,tsx,js,json,scss}\"", @@ -34,6 +34,7 @@ "better-auth": "^1.6.29", "chart.js": "^4.5.1", "classnames": "^2.5.1", + "disposable-email-domains-js": "^1.26.0", "drizzle-orm": "^0.45.2", "html-to-image": "^1.11.13", "lowlight": "^3.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad4ce0a..52ddee5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: classnames: specifier: ^2.5.1 version: 2.5.1 + disposable-email-domains-js: + specifier: ^1.26.0 + version: 1.26.0 drizzle-orm: specifier: ^0.45.2 version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.15.5)(kysely@0.29.5)(pg@8.23.0) @@ -3610,6 +3613,9 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + disposable-email-domains-js@1.26.0: + resolution: {integrity: sha512-pSqALZAiuw6HDC1HqhhtvShRnniUNObQ6kzwa5N280XbyxxCPBZnQQ7fO6Uk/CNTi4cAaKE1UZl3Mc30Zfj4UA==} + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -9463,6 +9469,8 @@ snapshots: dependencies: dequal: 2.0.3 + disposable-email-domains-js@1.26.0: {} + doctrine@2.1.0: dependencies: esutils: 2.0.3 diff --git a/src/app/api/auth/[...all]/route.ts b/src/app/api/auth/[...all]/route.ts index 8bb3465..8302278 100644 --- a/src/app/api/auth/[...all]/route.ts +++ b/src/app/api/auth/[...all]/route.ts @@ -1,7 +1,10 @@ import { toNextJsHandler } from 'better-auth/next-js'; import { auth } from '@/lib/auth/auth'; +import { clientIpHandlers } from '@/lib/auth/clientIp'; +import { trustedHops } from '@/lib/proxy/caller'; -// Better Auth's endpoints (sign-in, callback, session, sign-out) — all under -// /api/auth/*. -export const { GET, POST } = toNextJsHandler(auth); +// Better Auth's endpoints (sign-in, callback, session, sign-out, email +// codes), all under /api/auth/*. Each request first gets the client IP +// header Better Auth keys its limits on (see lib/auth/clientIp). +export const { GET, POST } = clientIpHandlers(toNextJsHandler(auth), trustedHops(process.env)); diff --git a/src/components/atoms/Button/Button.module.scss b/src/components/atoms/Button/Button.module.scss index a12af01..9aab427 100644 --- a/src/components/atoms/Button/Button.module.scss +++ b/src/components/atoms/Button/Button.module.scss @@ -33,7 +33,8 @@ position: relative; background-color: var(--seg-bg, transparent); border-radius: rem(4); - transition: background-color 0.25s ease, + transition: + background-color 0.25s ease, box-shadow 0.25s ease, color 0.25s ease; } @@ -47,7 +48,8 @@ overflow: hidden; background-color: var(--seg-bg, transparent); border-radius: rem(4); - transition: background-color 0.25s ease, + transition: + background-color 0.25s ease, box-shadow 0.25s ease, color 0.25s ease; @@ -193,12 +195,14 @@ // Bare text button — no fill, muted color (e.g. "Why these results?"). // With an icon (chevron) the segment shrinks to ride inline with the text. +// The colour suits dark sections; on a light surface set --plain-color +// (e.g. to --color-charcoal) on the button. .plain { gap: rem(6); .label { padding-inline: 0; - color: var(--color-media-bg); + color: var(--plain-color, var(--color-media-bg)); background-color: transparent; } @@ -206,7 +210,7 @@ align-self: center; width: rem(14); height: rem(14); - color: var(--color-media-bg); + color: var(--plain-color, var(--color-media-bg)); background-color: transparent; svg { @@ -226,7 +230,7 @@ .label, .icon { - color: var(--color-media-bg); + color: var(--plain-color, var(--color-media-bg)); background-color: transparent; } } diff --git a/src/components/atoms/Turnstile/Turnstile.tsx b/src/components/atoms/Turnstile/Turnstile.tsx new file mode 100644 index 0000000..8ea1007 --- /dev/null +++ b/src/components/atoms/Turnstile/Turnstile.tsx @@ -0,0 +1,85 @@ +'use client'; + +import Script from 'next/script'; +import { useCallback, useEffect, useRef } from 'react'; + +type TurnstileApi = { + render: (element: HTMLElement, options: Record) => string; + remove: (widgetId: string) => void; +}; + +declare global { + interface Window { + turnstile?: TurnstileApi; + } +} + +const SCRIPT_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'; + +type TurnstileProps = { + siteKey: string; + /** A fresh token, or null once it expires or the check fails. */ + onToken: (token: string | null) => void; + /** The script did not load (offline, blocked) or the check failed, so no token will come until it recovers. */ + onUnavailable: () => void; +}; + +/** + * Cloudflare Turnstile, the bot check in front of every email sign-in + * code. It stays invisible unless Cloudflare wants an interaction. A token + * is good for one request, so remount the widget (change its `key`) to get + * the next one. + */ +const Turnstile = ({ siteKey, onToken, onUnavailable }: TurnstileProps) => { + const container = useRef(null); + const widget = useRef(null); + const handlers = useRef({ onToken, onUnavailable }); + + useEffect(() => { + handlers.current = { onToken, onUnavailable }; + }); + + const render = useCallback(() => { + if (!container.current || !window.turnstile || widget.current) { + return; + } + + widget.current = window.turnstile.render(container.current, { + sitekey: siteKey, + appearance: 'interaction-only', + callback: (token: string) => handlers.current.onToken(token), + 'expired-callback': () => handlers.current.onToken(null), + 'error-callback': () => { + handlers.current.onToken(null); + handlers.current.onUnavailable(); + }, + }); + }, [siteKey]); + + // A remount after the script has loaded renders here. The first mount + // renders from the script's onReady. + useEffect(() => { + render(); + + return () => { + if (widget.current) { + window.turnstile?.remove(widget.current); + widget.current = null; + } + }; + }, [render]); + + return ( + <> +