From 92b1810c07ead9c92e0b3f4b8e47a5ebd09c7976 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Mon, 28 Sep 2026 16:23:01 +0200 Subject: [PATCH] fix(auth): surface sign-up failures, trust serving origin, unbreakable mail probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-hosted sign-up bounced back to the bootstrap screen for three compounding reasons: - better-auth's client resolves { data, error } on HTTP failure; the UI ignored res.error and showed "Account created — signing you in", hiding the real 403/429/500. - trustedOrigins was a static localhost-only list. Browsing the instance via LAN IP or custom port failed every auth POST once cookies existed. Origins are now derived from the request's public host (forwarded by nginx), so any origin serving the app works. - hasMailTransport threw when core.settings was unreadable (fresh volume, backend still migrating), so sign-up 500'd and the startup backfill could crash-loop the service. Both now degrade to "no transport". Verified live: LAN-style origin sign-up succeeds, missing core.settings logs a warn instead of exiting, and the frontend shows real errors. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- CHANGELOG.md | 6 ++++++ apps/auth/src/lib/auth.ts | 25 +++++++++++++++++++++---- apps/auth/src/lib/mail.ts | 13 ++++++++++--- apps/frontend/src/App.tsx | 15 +++++++++++++-- 4 files changed, 50 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb349a8..13fbb0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to Primora. Format follows [Keep a Changelog](https://keepac ## [Unreleased] +### Fixed + +- **Self-hosted sign-up works on any origin** — trusted origins are now derived from the request's public host (forwarded by nginx), so browsing the instance via a LAN IP, hostname, or custom port no longer fails every auth POST with a 403. Static `VITE_APP_URL`/`AUTH_BASE_URL` origins and `BETTER_AUTH_TRUSTED_ORIGINS` still apply. +- **Sign-up/sign-in failures are surfaced, not swallowed** — better-auth resolves `{ data, error }` instead of throwing, so a failed request previously showed "Account created — signing you in" and bounced back to the bootstrap screen. The real server error (invalid origin, rate limit, disabled sign-up) is now displayed. +- **Mail probe can no longer break auth** — `hasMailTransport` degrades to "no transport" instead of throwing when `core.settings` is unreadable, and the no-transport startup backfill is best-effort. Previously, auth could crash-loop while the backend was still migrating on a fresh volume — every sign-up 502'd during the window. + ## [0.8.1] - 2026-09-28 ### Fixed diff --git a/apps/auth/src/lib/auth.ts b/apps/auth/src/lib/auth.ts index 2357413..3f79312 100644 --- a/apps/auth/src/lib/auth.ts +++ b/apps/auth/src/lib/auth.ts @@ -53,7 +53,19 @@ export const auth = betterAuth({ appName: "Primora", secret: env.BETTER_AUTH_SECRET, baseURL: env.BETTER_AUTH_URL, - trustedOrigins: [env.VITE_APP_URL, env.AUTH_BASE_URL], + // Self-host: the browser reaches /auth through the same nginx origin as the + // app, so whatever host actually served this request is trusted — static + // localhost defaults alone 403 every sign-up on LAN IPs or custom ports. + // An attacker's cross-site Origin still won't match the server host. + trustedOrigins: (request) => { + const origins = [env.VITE_APP_URL, env.AUTH_BASE_URL]; + const host = request?.headers.get("x-forwarded-host") ?? request?.headers.get("host"); + if (host) { + const proto = request?.headers.get("x-forwarded-proto") ?? "http"; + origins.push(`${proto}://${host}`); + } + return origins; + }, database: authPool, emailAndPassword: { enabled: true, @@ -155,10 +167,15 @@ export async function runAuthMigrations() { await migrations.runMigrations(); } -/** No transport → existing unverified users can never resolve that state. */ +/** No transport → existing unverified users can never resolve that state. + * Best-effort: a startup failure here must never take the service down. */ export async function verifyUsersWithoutMailTransport() { - if (await hasMailTransport()) return; - await authPool.query(`update "user" set "emailVerified" = true where "emailVerified" = false`); + try { + if (await hasMailTransport()) return; + await authPool.query(`update "user" set "emailVerified" = true where "emailVerified" = false`); + } catch (error) { + console.warn(JSON.stringify({ level: "warn", msg: "verify_without_transport_failed", error })); + } } /** Bootstrap admins by email — run after migrations so the role column exists. */ diff --git a/apps/auth/src/lib/mail.ts b/apps/auth/src/lib/mail.ts index 5a453fd..62910ad 100644 --- a/apps/auth/src/lib/mail.ts +++ b/apps/auth/src/lib/mail.ts @@ -32,10 +32,17 @@ async function resolveMailConfig() { // True when a mail transport exists (Resend key or SMTP host). Verification // and reset mails only make sense when mail can actually leave the box. +// Mail is optional — a failed settings read must degrade to "no transport", +// never break sign-up. export async function hasMailTransport(): Promise { - const cfg = await resolveMailConfig(); - if ("resendApiKey" in cfg && cfg.resendApiKey) return true; - return "smtp" in cfg && Boolean(cfg.smtp); + try { + const cfg = await resolveMailConfig(); + if ("resendApiKey" in cfg && cfg.resendApiKey) return true; + return "smtp" in cfg && Boolean(cfg.smtp); + } catch (error) { + console.warn(JSON.stringify({ level: "warn", msg: "mail_transport_probe_failed", error })); + return false; + } } export async function sendTransactionalEmail(input: { diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 53b4355..d25f05d 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -735,11 +735,22 @@ export default function App() { await runPending("auth-submit", async () => { setAuthMessage(""); try { + // better-auth client resolves { data, error } instead of throwing on + // HTTP failures — surface res.error or the UI claims success while + // nothing was created. if (mode() === "sign-up") { - await authClient.signUp.email({ email: email(), password: password(), name: name() }); + const res = await authClient.signUp.email({ email: email(), password: password(), name: name() }); + if (res.error) { + setAuthMessage(res.error.message ?? "Sign-up failed"); + return; + } setAuthMessage("Account created — signing you in."); } else { - await authClient.signIn.email({ email: email(), password: password() }); + const res = await authClient.signIn.email({ email: email(), password: password() }); + if (res.error) { + setAuthMessage(res.error.message ?? "Sign-in failed"); + return; + } } } catch (error) { setAuthMessage(getErrorMessage(error, "Authentication failed"));