Repository navigation
feat(auth): sign in to Studio with an emailed code - #28
Merged
Merged
Conversation
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.
jm-rivera
added this pull request to stack #39
October 20, 2026 13:42
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.
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
marked this pull request as ready for review
October 23, 2026 09:17
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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'slimit_counter(src/lib/auth/codeLimit.ts), on the boundedcounterDbpool 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 503CODE_NOT_SENT, where Better Auth alone would report success.src/lib/auth/clientIp.tssetsx-dfo-client-ipon /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.
trustedProvidersis gone, so Google and GitHub link to an existing user only when they mark the address verified.safeNext(src/lib/auth/nextPath.ts) keepsnextonly 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 inSES_TOKEN_AUDIENCE, for temporary credentials for the role inSES_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_HOSTselects SMTP for local work. Email sign-in stays off untilSES_ROLE_ARN,SES_REGIONandSES_TOKEN_AUDIENCE(orSMTP_HOST),AUTH_EMAIL_FROMand 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 buildpass, andpnpm testpasses 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. Onnext dev, a code sent through a local SMTP server signed in, and in SES mode off Cloud Run a send answered 503CODE_NOT_SENT. No mail has gone through real SES yet.Screenshots
Before is #27's branch and after is this branch, both on
next devat 1440 by 900.