Просмотр исходного кода

Say which two addresses disagree when better-auth refuses a sign-in with Invalid origin, and warn at startup about a bad app URL (#337)

Bernt Christian Egeland 3 недель назад
Родитель
Сommit
626a65ecad

+ 2 - 1
messages/de/auth.json

@@ -13,7 +13,8 @@
     "errors": {
       "tooManyAttempts": "Zu viele Versuche, bitte versuchen Sie es später erneut.",
       "invalidCredentials": "Ungültige E-Mail-Adresse oder ungültiges Passwort",
-      "passkeyFailed": "Passkey-Anmeldung fehlgeschlagen. Versuchen Sie es erneut oder verwenden Sie E-Mail und Passwort."
+      "passkeyFailed": "Passkey-Anmeldung fehlgeschlagen. Versuchen Sie es erneut oder verwenden Sie E-Mail und Passwort.",
+      "invalidOrigin": "Sie haben Torqvoice unter {origin} geöffnet, es ist aber für {configured} konfiguriert. Setzen Sie NEXT_PUBLIC_APP_URL auf genau die Adresse, die Sie im Browser verwenden, und starten Sie Torqvoice neu."
     },
     "resetPasswordCta": "Hier zurücksetzen"
   },

+ 2 - 1
messages/en/auth.json

@@ -14,7 +14,8 @@
     "errors": {
       "tooManyAttempts": "Too many attempts, try again later.",
       "invalidCredentials": "Invalid email or password",
-      "passkeyFailed": "Passkey sign-in failed. Try again or use email and password."
+      "passkeyFailed": "Passkey sign-in failed. Try again or use email and password.",
+      "invalidOrigin": "You opened Torqvoice at {origin}, but it is configured for {configured}. Set NEXT_PUBLIC_APP_URL to the exact address you use in the browser, then restart Torqvoice."
     }
   },
   "signUp": {

+ 2 - 1
messages/es/auth.json

@@ -13,7 +13,8 @@
     "errors": {
       "tooManyAttempts": "Demasiados intentos, vuelva a intentarlo mas tarde.",
       "invalidCredentials": "Correo electrónico o contraseña no validos",
-      "passkeyFailed": "Error al iniciar sesión con passkey. Inténtelo de nuevo o use correo y contraseña."
+      "passkeyFailed": "Error al iniciar sesión con passkey. Inténtelo de nuevo o use correo y contraseña.",
+      "invalidOrigin": "Has abierto Torqvoice en {origin}, pero está configurado para {configured}. Pon en NEXT_PUBLIC_APP_URL exactamente la dirección que usas en el navegador y reinicia Torqvoice."
     },
     "resetPasswordCta": "Restablécela aquí"
   },

+ 2 - 1
messages/fr/auth.json

@@ -13,7 +13,8 @@
     "errors": {
       "tooManyAttempts": "Trop de tentatives, veuillez réessayer plus tard.",
       "invalidCredentials": "Adresse e-mail ou mot de passe invalide",
-      "passkeyFailed": "Échec de la connexion par passkey. Réessayez ou utilisez l'email et le mot de passe."
+      "passkeyFailed": "Échec de la connexion par passkey. Réessayez ou utilisez l'email et le mot de passe.",
+      "invalidOrigin": "Vous avez ouvert Torqvoice à l'adresse {origin}, mais il est configuré pour {configured}. Définissez NEXT_PUBLIC_APP_URL sur l'adresse exacte que vous utilisez dans le navigateur, puis redémarrez Torqvoice."
     },
     "resetPasswordCta": "Réinitialisez-le ici"
   },

+ 2 - 1
messages/it/auth.json

@@ -13,7 +13,8 @@
     "errors": {
       "tooManyAttempts": "Troppi tentativi, riprovi più tardi.",
       "invalidCredentials": "Email o password non validi",
-      "passkeyFailed": "Accesso con passkey fallito. Riprova o usa email e password."
+      "passkeyFailed": "Accesso con passkey fallito. Riprova o usa email e password.",
+      "invalidOrigin": "Hai aperto Torqvoice su {origin}, ma è configurato per {configured}. Imposta NEXT_PUBLIC_APP_URL sull'indirizzo esatto che usi nel browser e riavvia Torqvoice."
     },
     "resetPasswordCta": "Reimpostala qui"
   },

+ 2 - 1
messages/lt/auth.json

@@ -13,7 +13,8 @@
     "errors": {
       "tooManyAttempts": "Per daug bandymų, pabandykite vėliau.",
       "invalidCredentials": "Neteisingas el. paštas arba slaptažodis",
-      "passkeyFailed": "Prisijungimas su prieigos raktu nepavyko. Bandykite dar kartą arba naudokite el. paštą ir slaptažodį."
+      "passkeyFailed": "Prisijungimas su prieigos raktu nepavyko. Bandykite dar kartą arba naudokite el. paštą ir slaptažodį.",
+      "invalidOrigin": "Atidarėte Torqvoice adresu {origin}, bet jis sukonfigūruotas adresui {configured}. Nustatykite NEXT_PUBLIC_APP_URL tiksliai tokį adresą, kokį naudojate naršyklėje, ir paleiskite Torqvoice iš naujo."
     },
     "resetPasswordCta": "Iš naujo nustatykite čia"
   },

+ 2 - 1
messages/nb/auth.json

@@ -13,7 +13,8 @@
     "errors": {
       "tooManyAttempts": "For mange forsøk, prøv igjen senere.",
       "invalidCredentials": "Ugyldig e-post eller passord",
-      "passkeyFailed": "Passkey-innlogging mislyktes. Prøv igjen eller bruk e-post og passord."
+      "passkeyFailed": "Passkey-innlogging mislyktes. Prøv igjen eller bruk e-post og passord.",
+      "invalidOrigin": "Du åpnet Torqvoice på {origin}, men den er satt opp for {configured}. Sett NEXT_PUBLIC_APP_URL til nøyaktig den adressen du bruker i nettleseren, og start Torqvoice på nytt."
     },
     "resetPasswordCta": "Tilbakestill det her"
   },

+ 2 - 1
messages/nl/auth.json

@@ -13,7 +13,8 @@
     "errors": {
       "tooManyAttempts": "Te veel pogingen, probeer het later opnieuw.",
       "invalidCredentials": "Ongeldig e-mailadres of wachtwoord",
-      "passkeyFailed": "Passkey-aanmelding mislukt. Probeer het opnieuw of gebruik e-mail en wachtwoord."
+      "passkeyFailed": "Passkey-aanmelding mislukt. Probeer het opnieuw of gebruik e-mail en wachtwoord.",
+      "invalidOrigin": "U opende Torqvoice op {origin}, maar het is ingesteld voor {configured}. Zet NEXT_PUBLIC_APP_URL op precies het adres dat u in de browser gebruikt en herstart Torqvoice."
     },
     "resetPasswordCta": "Stel het hier opnieuw in"
   },

+ 2 - 1
messages/pl/auth.json

@@ -13,7 +13,8 @@
     "errors": {
       "tooManyAttempts": "Zbyt wiele prób, spróbuj ponownie później.",
       "invalidCredentials": "Nieprawidłowy e-mail lub hasło",
-      "passkeyFailed": "Logowanie za pomocą passkey nie powiodło się. Spróbuj ponownie lub użyj adresu e-mail i hasła."
+      "passkeyFailed": "Logowanie za pomocą passkey nie powiodło się. Spróbuj ponownie lub użyj adresu e-mail i hasła.",
+      "invalidOrigin": "Otwarto Torqvoice pod adresem {origin}, ale jest skonfigurowany dla {configured}. Ustaw NEXT_PUBLIC_APP_URL na dokładnie ten adres, którego używasz w przeglądarce, i uruchom Torqvoice ponownie."
     },
     "resetPasswordCta": "Zresetuj je tutaj"
   },

+ 2 - 1
messages/pt-BR/auth.json

@@ -13,7 +13,8 @@
     "errors": {
       "tooManyAttempts": "Muitas tentativas. Tente novamente mais tarde.",
       "invalidCredentials": "E-mail ou senha inválidos",
-      "passkeyFailed": "Falha ao entrar com passkey. Tente novamente ou use e-mail e senha."
+      "passkeyFailed": "Falha ao entrar com passkey. Tente novamente ou use e-mail e senha.",
+      "invalidOrigin": "Você abriu o Torqvoice em {origin}, mas ele está configurado para {configured}. Defina NEXT_PUBLIC_APP_URL com exatamente o endereço que você usa no navegador e reinicie o Torqvoice."
     },
     "resetPasswordCta": "Redefina aqui"
   },

+ 2 - 1
messages/ru/auth.json

@@ -13,7 +13,8 @@
     "errors": {
       "tooManyAttempts": "Слишком много попыток, попробуйте позже.",
       "invalidCredentials": "Неверный адрес эл. почты или пароль",
-      "passkeyFailed": "Вход по ключу доступа не удался. Попробуйте снова или используйте эл. почту и пароль."
+      "passkeyFailed": "Вход по ключу доступа не удался. Попробуйте снова или используйте эл. почту и пароль.",
+      "invalidOrigin": "Вы открыли Torqvoice по адресу {origin}, но он настроен на {configured}. Укажите в NEXT_PUBLIC_APP_URL точно тот адрес, который используете в браузере, и перезапустите Torqvoice."
     },
     "resetPasswordCta": "Сбросьте его здесь"
   },

+ 2 - 1
messages/tr/auth.json

@@ -13,7 +13,8 @@
     "errors": {
       "tooManyAttempts": "Çok fazla deneme yapıldı, lütfen daha sonra tekrar deneyin.",
       "invalidCredentials": "Geçersiz e-posta veya şifre",
-      "passkeyFailed": "Passkey ile giriş başarısız. Tekrar deneyin veya e-posta ve şifre kullanın."
+      "passkeyFailed": "Passkey ile giriş başarısız. Tekrar deneyin veya e-posta ve şifre kullanın.",
+      "invalidOrigin": "Torqvoice'u {origin} adresinde açtınız ama {configured} için yapılandırılmış. NEXT_PUBLIC_APP_URL değerini tarayıcıda kullandığınız adresle birebir aynı yapın ve Torqvoice'u yeniden başlatın."
     },
     "resetPasswordCta": "Buradan sıfırlayın"
   },

+ 140 - 0
src/__tests__/lib/auth-origin-hint.test.ts

@@ -0,0 +1,140 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import {
+  configuredOrigin,
+  explainInvalidOrigin,
+  invalidOriginMessage,
+  requestOrigin,
+  warnAboutAppUrl,
+} from '@/lib/auth-origin-hint'
+
+function refusal(body: unknown, init: ResponseInit = {}): Response {
+  return new Response(JSON.stringify(body), {
+    status: 403,
+    headers: { 'content-type': 'application/json', 'set-cookie': 'a=b', ...init.headers },
+    ...init,
+  })
+}
+
+function request(headers: Record<string, string> = {}): Request {
+  return new Request('http://localhost:3000/api/public/auth/sign-in/email', {
+    method: 'POST',
+    headers,
+  })
+}
+
+describe('requestOrigin', () => {
+  it('reduces the header to scheme, host and port', () => {
+    expect(requestOrigin(new Headers({ origin: 'http://192.168.1.5:3000' }))).toBe(
+      'http://192.168.1.5:3000'
+    )
+    expect(
+      requestOrigin(new Headers({ referer: 'https://shop.example.com/auth/sign-in?x=1' }))
+    ).toBe('https://shop.example.com')
+  })
+
+  it('drops anything that is not a URL rather than echoing it', () => {
+    expect(requestOrigin(new Headers({ origin: 'null' }))).toBeNull()
+    expect(requestOrigin(new Headers({ origin: '<script>alert(1)</script>' }))).toBeNull()
+    expect(requestOrigin(new Headers({ origin: `http://${'a'.repeat(300)}.com` }))).toBeNull()
+    expect(requestOrigin(new Headers())).toBeNull()
+  })
+})
+
+describe('explainInvalidOrigin', () => {
+  afterEach(() => {
+    vi.unstubAllEnvs()
+  })
+
+  it('names both addresses and keeps the refusal', async () => {
+    vi.stubEnv('NEXT_PUBLIC_APP_URL', 'http://localhost:3000')
+    const res = await explainInvalidOrigin(
+      request({ origin: 'http://192.168.1.5:3000' }),
+      refusal({ code: 'INVALID_ORIGIN', message: 'Invalid origin' })
+    )
+    expect(res.status).toBe(403)
+    expect(res.headers.get('set-cookie')).toBe('a=b')
+    const body = await res.json()
+    expect(body.code).toBe('INVALID_ORIGIN')
+    expect(body.origin).toBe('http://192.168.1.5:3000')
+    expect(body.configured).toBe('http://localhost:3000')
+    expect(body.message).toContain('http://192.168.1.5:3000')
+    expect(body.message).toContain('http://localhost:3000')
+    expect(body.message).toContain('NEXT_PUBLIC_APP_URL')
+  })
+
+  it('matches on the message alone, for older bodies without a code', async () => {
+    vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://shop.example.com')
+    const res = await explainInvalidOrigin(
+      request({ origin: 'http://shop.example.com' }),
+      refusal({ message: 'Invalid origin' })
+    )
+    const body = await res.json()
+    expect(body.code).toBe('INVALID_ORIGIN')
+    expect(body.configured).toBe('https://shop.example.com')
+  })
+
+  it('leaves every other response alone', async () => {
+    const ok = new Response('{"ok":true}', {
+      status: 200,
+      headers: { 'content-type': 'application/json' },
+    })
+    expect(await explainInvalidOrigin(request(), ok)).toBe(ok)
+    const other403 = refusal({ code: 'FORBIDDEN', message: 'Nope' })
+    expect(await explainInvalidOrigin(request(), other403)).toBe(other403)
+    const html = new Response('<h1>Forbidden</h1>', {
+      status: 403,
+      headers: { 'content-type': 'text/html' },
+    })
+    expect(await explainInvalidOrigin(request(), html)).toBe(html)
+    const broken = new Response('not json', {
+      status: 403,
+      headers: { 'content-type': 'application/json' },
+    })
+    expect(await explainInvalidOrigin(request(), broken)).toBe(broken)
+  })
+
+  it('never repeats the trusted list, only the two addresses', async () => {
+    vi.stubEnv('NEXT_PUBLIC_APP_URL', 'http://localhost:3000')
+    vi.stubEnv('EXPO_DEV_ORIGIN', 'exp://10.0.0.5:8081')
+    const res = await explainInvalidOrigin(
+      request({ origin: 'http://192.168.1.5:3000' }),
+      refusal({ code: 'INVALID_ORIGIN', message: 'Invalid origin' })
+    )
+    expect(await res.text()).not.toContain('exp://')
+  })
+})
+
+describe('invalidOriginMessage', () => {
+  it('still explains when one side is unknown', () => {
+    expect(invalidOriginMessage('http://a:3000', null)).toContain('http://a:3000')
+    expect(invalidOriginMessage(null, null)).toContain('NEXT_PUBLIC_APP_URL')
+  })
+})
+
+describe('warnAboutAppUrl', () => {
+  afterEach(() => {
+    vi.unstubAllEnvs()
+    vi.restoreAllMocks()
+  })
+
+  it('warns when unset, invalid, or localhost in production', () => {
+    const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+    vi.stubEnv('NEXT_PUBLIC_APP_URL', '')
+    warnAboutAppUrl()
+    vi.stubEnv('NEXT_PUBLIC_APP_URL', 'torqvoice.example.com')
+    warnAboutAppUrl()
+    vi.stubEnv('NEXT_PUBLIC_APP_URL', 'http://localhost:3000')
+    vi.stubEnv('NODE_ENV', 'production')
+    warnAboutAppUrl()
+    expect(warn).toHaveBeenCalledTimes(3)
+    expect(configuredOrigin()).toBe('http://localhost:3000')
+  })
+
+  it('stays quiet for a proper public address', () => {
+    const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+    vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://torqvoice.example.com')
+    vi.stubEnv('NODE_ENV', 'production')
+    warnAboutAppUrl()
+    expect(warn).not.toHaveBeenCalled()
+  })
+})

+ 6 - 0
src/app/(public)/auth/sign-in/sign-in-form.tsx

@@ -42,8 +42,14 @@ function SignInFormInner({
     try {
       const result = await signIn.email({ email, password })
       if (result.error) {
+        // The origin refusal carries both addresses, see lib/auth-origin-hint.
+        const refusal = result.error as { code?: string; origin?: string; configured?: string }
         if (result.error.status === 429) {
           setError(t('errors.tooManyAttempts'))
+        } else if (refusal.code === 'INVALID_ORIGIN' && refusal.origin && refusal.configured) {
+          setError(
+            t('errors.invalidOrigin', { origin: refusal.origin, configured: refusal.configured })
+          )
         } else {
           setError(result.error.message || t('errors.invalidCredentials'))
         }

+ 3 - 2
src/app/api/public/auth/[...all]/route.ts

@@ -5,6 +5,7 @@ import { rateLimit } from '@/lib/rate-limit'
 import { toNextJsHandler } from 'better-auth/next-js'
 import { db } from '@/lib/db'
 import { logAudit } from '@/lib/audit'
+import { explainInvalidOrigin } from '@/lib/auth-origin-hint'
 
 const { POST: authPOST, GET } = toNextJsHandler(auth)
 
@@ -72,7 +73,7 @@ async function POST(request: Request) {
   if (isAuthAttempt) {
     // Clone body before better-auth consumes it
     const cloned = request.clone()
-    const response = await authPOST(request)
+    const response = await explainInvalidOrigin(cloned, await authPOST(request))
 
     // Log failed authentication attempts (fire-and-forget to avoid timing side-channels)
     if (!response.ok) {
@@ -116,7 +117,7 @@ async function POST(request: Request) {
     return response
   }
 
-  return authPOST(request)
+  return explainInvalidOrigin(request, await authPOST(request))
 }
 
 export { GET, POST }

+ 2 - 0
src/instrumentation.ts

@@ -16,6 +16,8 @@ export async function register() {
       processIntegrationJobs,
       cleanupIntegrationLogs,
     } = await import('./cronTasks')
+    const { warnAboutAppUrl } = await import('./lib/auth-origin-hint')
+    warnAboutAppUrl()
     checkLicenses()
     checkSubscriptions()
     processRecurringInvoices()

+ 119 - 0
src/lib/auth-origin-hint.ts

@@ -0,0 +1,119 @@
+/**
+ * A readable reason for better-auth's "Invalid origin".
+ *
+ * better-auth refuses any request whose Origin header is not on its trusted
+ * list, and says only "Invalid origin". On a self-hosted install that
+ * nearly always means NEXT_PUBLIC_APP_URL differs from the address in the
+ * browser, so the refusal is rewritten to say which two addresses disagree
+ * and which variable to set. The refusal itself is left alone: same status,
+ * same headers, nothing gets through that was refused before.
+ *
+ * What the message names is already public. The address the person used is
+ * their own address bar, and the configured one ships to every browser in
+ * the bundle as NEXT_PUBLIC_APP_URL. The full trusted list, which can hold
+ * development and app origins, is not repeated.
+ */
+
+export const INVALID_ORIGIN_CODE = 'INVALID_ORIGIN'
+const BETTER_AUTH_MESSAGE = 'Invalid origin'
+const MAX_ORIGIN_LENGTH = 200
+
+/** Scheme, host and port only; anything unparseable is dropped, not echoed. */
+function toOrigin(raw: string | null | undefined): string | null {
+  if (!raw) return null
+  try {
+    const { origin } = new URL(raw)
+    if (origin === 'null' || origin.length > MAX_ORIGIN_LENGTH) return null
+    return origin
+  } catch {
+    return null
+  }
+}
+
+/** The origin the browser sent, the same way better-auth reads it. */
+export function requestOrigin(headers: Headers): string | null {
+  return toOrigin(headers.get('origin') || headers.get('referer'))
+}
+
+export function configuredOrigin(): string | null {
+  return toOrigin(process.env.NEXT_PUBLIC_APP_URL)
+}
+
+export function invalidOriginMessage(origin: string | null, configured: string | null): string {
+  const fix =
+    'Set NEXT_PUBLIC_APP_URL to the exact address you use in the browser, then restart Torqvoice.'
+  if (origin && configured)
+    return `You opened Torqvoice at ${origin}, but it is configured for ${configured}. ${fix}`
+  if (origin)
+    return `You opened Torqvoice at ${origin}, which is not the address it is configured for. ${fix}`
+  return `The address you opened Torqvoice at is not the one it is configured for. ${fix}`
+}
+
+interface RefusalBody {
+  code?: unknown
+  message?: unknown
+}
+
+/**
+ * The same 403, with a body that says why. Any other response, and any 403
+ * that is not better-auth's origin refusal, passes through untouched.
+ */
+export async function explainInvalidOrigin(
+  request: Request,
+  response: Response
+): Promise<Response> {
+  if (response.status !== 403) return response
+  if (!(response.headers.get('content-type') ?? '').includes('application/json')) return response
+  let body: RefusalBody | null
+  try {
+    body = (await response.clone().json()) as RefusalBody | null
+  } catch {
+    return response
+  }
+  if (body?.code !== INVALID_ORIGIN_CODE && body?.message !== BETTER_AUTH_MESSAGE) return response
+
+  const origin = requestOrigin(request.headers)
+  const configured = configuredOrigin()
+  const headers = new Headers(response.headers)
+  headers.delete('content-length')
+  headers.set('content-type', 'application/json')
+  return new Response(
+    JSON.stringify({
+      code: INVALID_ORIGIN_CODE,
+      message: invalidOriginMessage(origin, configured),
+      origin,
+      configured,
+    }),
+    { status: response.status, statusText: response.statusText, headers }
+  )
+}
+
+/**
+ * Said once at startup, since the mismatch itself only shows when someone
+ * tries to sign in. Both cases are the ones support sees.
+ */
+export function warnAboutAppUrl(): void {
+  const raw = process.env.NEXT_PUBLIC_APP_URL
+  if (!raw) {
+    console.warn(
+      '[auth] NEXT_PUBLIC_APP_URL is not set. Set it to the address people use in the browser, or sign-in is refused with "Invalid origin" and links in emails have no address.'
+    )
+    return
+  }
+  const origin = toOrigin(raw)
+  if (!origin) {
+    console.warn(
+      `[auth] NEXT_PUBLIC_APP_URL is not a valid URL. Set it to the full address people use in the browser, such as https://torqvoice.example.com.`
+    )
+    return
+  }
+  const { hostname } = new URL(origin)
+  if (
+    process.env.NODE_ENV === 'production' &&
+    (hostname === 'localhost' || hostname === '127.0.0.1')
+  ) {
+    console.warn(
+      `[auth] NEXT_PUBLIC_APP_URL is ${origin}. Signing in from any other address is refused with "Invalid origin"; set it to the address people use in the browser.`
+    )
+  }
+}