Skip to content

feat(auth): sign in to Studio with an emailed code - #28

Merged
jm-rivera merged 5 commits into
devfrom
feat/email-otp-sign-in
Oct 23, 2026
Merged

jm-rivera merged 5 commits into
devfrom
feat/email-otp-sign-in

Conversation

@jm-rivera

@jm-rivera jm-rivera commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Adds sign-in by emailed code to /login through Better Auth's emailOTP plugin. Google, GitHub and Microsoft stay first, and a quiet "Use email instead" toggle opens an email field and only then loads Turnstile. The six-digit code lasts 10 minutes, allows three wrong tries, works once, and is stored encrypted under the auth secret. The first code sign-in for an address creates the account. It builds on #27's shared limiter.

Every send passes Cloudflare Turnstile, is refused for throwaway domains (disposable-email-domains-js), and counts against 5 codes per inbox and 20 per network each hour in #27's limit_counter (src/lib/auth/codeLimit.ts), on the bounded counterDb pool the other counters use. A send refused by either cap gives back its hit on both. The unused emailOTP endpoints are disabled, and a send that fails answers 503 CODE_NOT_SENT, where Better Auth alone would report success.

src/lib/auth/clientIp.ts sets x-dfo-client-ip on /api/auth requests from the trusted X-Forwarded-For entry (TRUSTED_PROXY_HOPS), and Better Auth's rate limiter, Turnstile and the caps read it. Behind Cloud Run, Better Auth's default put every visitor in one rate-limit bucket.

Two changes touch existing sign-in. trustedProviders is gone, so Google and GitHub link to an existing user only when they mark the address verified. safeNext (src/lib/auth/nextPath.ts) keeps next only when it resolves on this site, which closes /\evil.example.

Deployed, mail goes through the Amazon SES v2 API with no AWS secret (src/lib/mail/ses.ts). AWS STS trades the Cloud Run service account's Google ID token, minted for the audience in SES_TOKEN_AUDIENCE, for temporary credentials for the role in SES_ROLE_ARN. ONE's AWS account has one sender role per environment, each requiring its own audience, and DevelopmentFinanceObservatory/infra#48 sets all three SES values for dev and prod. Each request times out at 10 seconds and is tried twice. SMTP_HOST selects SMTP for local work. Email sign-in stays off until SES_ROLE_ARN, SES_REGION and SES_TOKEN_AUDIENCE (or SMTP_HOST), AUTH_EMAIL_FROM and both Turnstile keys are set, so this can merge before the Turnstile secret keys are in Secret Manager. Failed database queries log their parameters across the site, which needs its own PR.

Testing: typecheck, ESLint and pnpm build pass, and pnpm test passes 1723 of 1723 on Postgres 18.4. Wiring tests run a real Better Auth with a stub Turnstile server and cover each refusal, encrypted storage, single use, the try limit and a failed send. A wire test runs the SES transport against a local STS and SES and checks one token mint for two sends, the configured audience, the signing key and region, and a timeout after two attempts. On next dev, a code sent through a local SMTP server signed in, and in SES mode off Cloud Run a send answered 503 CODE_NOT_SENT. No mail has gone through real SES yet.

Screenshots

Before is #27's branch and after is this branch, both on next dev at 1440 by 900.

Change Before After
Sign-in card before 01 after 01
Email form open New after 02
Code sent New after 03

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.
Deployed, mail goes through the Amazon SES v2 API. AWS STS trades the
Cloud Run runtime service account's Google ID token, minted for the
audience dfo-website-ses, for temporary credentials for the role in
SES_ROLE_ARN, so no AWS secret is stored. SMTP stays for local work
against a catcher.

The metadata-server token code moves from apisAuth.ts to
googleIdToken.ts so both callers share it. The transport is built once
per process, so the SDK keeps its credentials between sends.
Better Auth only lower-cases an email address, so an email code makes a
separate account for each spelling of one inbox: ana.silva@gmail.com,
anasilva+1@gmail.com and ana.silva@googlemail.com are three users.
Keyed by user id, each had its own 75 prompts a day, and the network
cap on codes let one inbox open about 20 such accounts an hour.

spendPrompt now keys a signed-in prompter by a SHA-256 digest of the
inbox its email reaches, so every account on one inbox draws on one
allowance and limit_counter still holds no address. inboxOf moves from
codeLimit.ts to src/lib/auth/inbox.ts, shared by the code caps and the
Studio.
Base automatically changed from feat/studio-prompt-limits to dev October 20, 2026 15:56
ONE's SES sender roles require a different token audience in each
environment, so SES_TOKEN_AUDIENCE replaces the fixed dfo-website-ses.
SES is selected only when SES_ROLE_ARN, SES_REGION and SES_TOKEN_AUDIENCE
are all set, and a partial set names the missing ones and turns mail off.
spendPrompt keeps dev's reservation receipts and this branch's per-inbox
key for signed-in users. The sign-in code caps now count on counterDb, the
bounded pool dev gave every other counter, and an email sign-in event
carries the request headers as a provider sign-in does.
@jm-rivera
jm-rivera marked this pull request as ready for review October 23, 2026 09:17
@jm-rivera
jm-rivera merged commit f42bca0 into dev Oct 23, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant