Skip to content

Commit af5779b

Browse files
authored
fix(server): auto-add localhost trusted origin in dev (#128)
1 parent fa8a758 commit af5779b

File tree

16 files changed

+444
-67
lines changed

16 files changed

+444
-67
lines changed

docs/content/1.getting-started/2.configuration.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,10 +144,12 @@ The module resolves `siteUrl` using this priority:
144144
| Priority | Source | When Used |
145145
|----------|--------|-----------|
146146
| 1 | `runtimeConfig.public.siteUrl` | Explicit config (always wins) |
147-
| 2 | Request URL | Auto-detected from HTTP request |
147+
| 2 | Request URL | Auto-detected from the current Nitro request (`event`) |
148148
| 3 | `VERCEL_URL`, `CF_PAGES_URL`, `URL` | Platform env vars (Vercel, Cloudflare, Netlify) |
149149
| 4 | `http://localhost:3000` | Development only |
150150

151+
In server handlers, pass `event` to `serverAuth(event)` so request URL detection can run. In non-request contexts (seed scripts, tasks, startup plugins), the module uses environment/platform fallbacks.
152+
151153
Set an explicit site URL in `nuxt.config.ts` for deterministic OAuth callbacks and origin checks:
152154

153155
```ts [nuxt.config.ts]

docs/content/1.getting-started/6.how-it-works.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ On the client side, `useUserSession` initializes the Better Auth client. It esta
2323
|---------|------------------|------|
2424
| Server handler | Mounts Better Auth at `/api/auth/*` | Server |
2525
| Composables | `useUserSession()` for reactive state | Client |
26-
| Server utils | `serverAuth()`, `getUserSession(event)`, etc. | Server |
26+
| Server utils | `serverAuth(event?)`, `getUserSession(event)`, etc. | Server |
2727
| Route protection | `routeRules.auth` and `definePageMeta({ auth })` | Both |
2828
| Type augmentation | Inferred `AuthUser` and `AuthSession` types | Build |
2929

docs/content/2.core-concepts/1.server-auth.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ title: serverAuth()
33
description: When to reach for the full Better Auth server instance.
44
---
55

6-
`serverAuth(event?)` returns the Better Auth instance (module-level singleton). Pass the event for accurate URL detection on first initialization. Prefer the helper utilities for common checks, and reach for `serverAuth()` when you need the full Better Auth API or plugin-specific endpoints.
6+
`serverAuth(event?)` returns the Better Auth instance (module-level singleton). In Nitro handlers, you should pass `event` so the module can resolve request-aware base URLs on initialization. Outside request contexts (seed scripts, tasks, plugins), you can call `serverAuth()` without an event.
77

88
## When to Use What
99

docs/content/2.core-concepts/4.auto-imports-aliases.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ description: What the module registers for you.
1111

1212
**Server**
1313

14-
- `serverAuth()`
14+
- `serverAuth(event?)`
1515
- `getUserSession(event)`
1616
- `requireUserSession(event, options?)`
1717

@@ -27,7 +27,7 @@ description: What the module registers for you.
2727
export default defineEventHandler(async (event) => {
2828
// These are auto-imported, no import statement needed
2929
const session = await getUserSession(event)
30-
const auth = serverAuth()
30+
const auth = serverAuth(event)
3131
})
3232
```
3333

docs/content/5.api/2.server-utils.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ await auth.api.getSession({
3434
```
3535

3636
::note
37-
Use `serverAuth()` to access advanced Better Auth features not exposed by the helper wrappers.
37+
Use `serverAuth(event)` in request handlers to access advanced Better Auth features not exposed by the helper wrappers. In non-request contexts, you can call `serverAuth()` without an event.
3838
::
3939

4040
## getUserSession
@@ -112,4 +112,3 @@ export default defineEventHandler(async (event) => {
112112
return getTeamSettings(teamId)
113113
})
114114
```
115-

docs/content/6.troubleshooting/1.faq.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,8 @@ Restart dev and inspect [http://localhost:3000/api/_better-auth/config](http://l
9191

9292
- Verify `config.server.baseURL` matches your local origin exactly.
9393
- Verify `config.server.trustedOrigins` includes your local origin.
94+
95+
When `runtimeConfig.public.siteUrl` points to production during local development, the module still adds local dev origins to `trustedOrigins` (request origin plus localhost fallback). This supports `--host` and LAN testing as long as your request origin is stable.
9496
::
9597

9698
::important

src/runtime/server/utils/auth.ts

Lines changed: 132 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Auth } from 'better-auth'
1+
import type { Auth, BetterAuthOptions } from 'better-auth'
22
import type { H3Event } from 'h3'
33
import { createDatabase, db } from '#auth/database'
44
import { createSecondaryStorage } from '#auth/secondary-storage'
@@ -48,12 +48,36 @@ function validateURL(url: string): string {
4848
}
4949
}
5050

51+
function resolveConfiguredSiteUrl(config: ReturnType<typeof useRuntimeConfig>): string | undefined {
52+
if (typeof config.public.siteUrl !== 'string' || !config.public.siteUrl)
53+
return undefined
54+
55+
return validateURL(config.public.siteUrl)
56+
}
57+
58+
function resolveEventOrigin(event?: H3Event): string | undefined {
59+
if (!event)
60+
return undefined
61+
62+
const host = getRequestHost(event, { xForwardedHost: true })
63+
const protocol = getRequestProtocol(event, { xForwardedProto: true })
64+
if (!host || !protocol)
65+
return undefined
66+
67+
try {
68+
return validateURL(`${protocol}://${host}`)
69+
}
70+
catch {
71+
return undefined
72+
}
73+
}
74+
5175
/**
5276
* Get the Nitro origin URL.
5377
* Adapted from nuxt-site-config by @harlan-zw
5478
* @see https://github.com/harlan-zw/nuxt-site-config/blob/main/packages/kit/src/util.ts
5579
*/
56-
function getNitroOrigin(e?: H3Event): string | undefined {
80+
function getNitroOrigin(): string | undefined {
5781
const cert = process.env.NITRO_SSL_CERT
5882
const key = process.env.NITRO_SSL_KEY
5983
let host: string | undefined = process.env.NITRO_HOST || process.env.HOST
@@ -73,10 +97,6 @@ function getNitroOrigin(e?: H3Event): string | undefined {
7397
host = withoutProtocol(origin)
7498
protocol = origin.includes('https') ? 'https' : 'http'
7599
}
76-
else if (e) {
77-
host = getRequestHost(e, { xForwardedHost: true }) || host
78-
protocol = getRequestProtocol(e, { xForwardedProto: true }) || protocol
79-
}
80100
}
81101
catch {
82102
// JSON parse failed, continue with env fallbacks
@@ -102,46 +122,122 @@ function getNitroOrigin(e?: H3Event): string | undefined {
102122
return `${protocol}://${host}${portSuffix}`
103123
}
104124

125+
function resolveEnvironmentOrigin(): { origin: string, source: string } | undefined {
126+
const nitroOrigin = getNitroOrigin()
127+
if (nitroOrigin)
128+
return { origin: validateURL(nitroOrigin), source: 'Nitro environment detection' }
129+
130+
if (process.env.VERCEL_URL)
131+
return { origin: validateURL(`https://${process.env.VERCEL_URL}`), source: 'VERCEL_URL' }
132+
133+
if (process.env.CF_PAGES_URL)
134+
return { origin: validateURL(`https://${process.env.CF_PAGES_URL}`), source: 'CF_PAGES_URL' }
135+
136+
if (process.env.URL)
137+
return { origin: validateURL(process.env.URL.startsWith('http') ? process.env.URL : `https://${process.env.URL}`), source: 'URL' }
138+
139+
return undefined
140+
}
141+
142+
function resolveDevFallback(): { origin: string, source: string } | undefined {
143+
if (!import.meta.dev)
144+
return undefined
145+
146+
return { origin: 'http://localhost:3000', source: 'development fallback' }
147+
}
148+
105149
function getBaseURL(event?: H3Event): string {
106150
const config = useRuntimeConfig()
151+
const configuredSiteUrl = resolveConfiguredSiteUrl(config)
152+
if (configuredSiteUrl)
153+
return configuredSiteUrl
154+
155+
const eventOrigin = resolveEventOrigin(event)
156+
if (eventOrigin) {
157+
logInferredBaseURL(eventOrigin, 'request origin')
158+
return eventOrigin
159+
}
160+
161+
const environmentOrigin = resolveEnvironmentOrigin()
162+
if (environmentOrigin) {
163+
logInferredBaseURL(environmentOrigin.origin, environmentOrigin.source)
164+
return environmentOrigin.origin
165+
}
166+
167+
const devFallback = resolveDevFallback()
168+
if (devFallback) {
169+
logInferredBaseURL(devFallback.origin, devFallback.source)
170+
return devFallback.origin
171+
}
172+
173+
throw new Error('siteUrl required. Set NUXT_PUBLIC_SITE_URL.')
174+
}
175+
176+
function dedupeOrigins(origins: readonly string[]): string[] {
177+
return [...new Set(origins)]
178+
}
107179

108-
// 1. Explicit config (highest priority)
109-
if (config.public.siteUrl && typeof config.public.siteUrl === 'string')
110-
return validateURL(config.public.siteUrl)
180+
function getDevTrustedOrigins(): string[] {
181+
const fallbackOrigin = 'http://localhost:3000'
182+
const nitroOrigin = getNitroOrigin()
183+
if (!nitroOrigin)
184+
return [fallbackOrigin]
111185

112-
// 2. Nitro origin detection (handles dev proxy, request headers)
113-
const nitroOrigin = getNitroOrigin(event)
114-
if (nitroOrigin) {
115-
const inferredBaseURL = validateURL(nitroOrigin)
116-
logInferredBaseURL(inferredBaseURL, 'Nitro/request origin detection')
117-
return inferredBaseURL
186+
try {
187+
const url = new URL(nitroOrigin)
188+
const protocol = url.protocol === 'https:' ? 'https' : 'http'
189+
const port = url.port || '3000'
190+
const localhostOrigin = `${protocol}://localhost:${port}`
191+
return dedupeOrigins([localhostOrigin, url.origin])
118192
}
193+
catch {
194+
return [fallbackOrigin]
195+
}
196+
}
119197

120-
// 3. Platform env vars (fallback for non-request contexts)
121-
if (process.env.VERCEL_URL) {
122-
const inferredBaseURL = validateURL(`https://${process.env.VERCEL_URL}`)
123-
logInferredBaseURL(inferredBaseURL, 'VERCEL_URL')
124-
return inferredBaseURL
198+
function getRequestOrigin(request?: Request): string | undefined {
199+
if (!request)
200+
return undefined
201+
202+
try {
203+
return new URL(request.url).origin
125204
}
126-
if (process.env.CF_PAGES_URL) {
127-
const inferredBaseURL = validateURL(`https://${process.env.CF_PAGES_URL}`)
128-
logInferredBaseURL(inferredBaseURL, 'CF_PAGES_URL')
129-
return inferredBaseURL
205+
catch {
206+
return undefined
207+
}
208+
}
209+
210+
function withDevTrustedOrigins(
211+
trustedOrigins: BetterAuthOptions['trustedOrigins'] | undefined,
212+
hasExplicitSiteUrl: boolean,
213+
): BetterAuthOptions['trustedOrigins'] | undefined {
214+
if (!import.meta.dev || !hasExplicitSiteUrl)
215+
return trustedOrigins
216+
217+
const devOrigins = getDevTrustedOrigins()
218+
const mergeOrigins = (origins: readonly (string | null | undefined)[], request?: Request): string[] => {
219+
const validOrigins = origins.filter((origin): origin is string => typeof origin === 'string')
220+
const requestOrigin = getRequestOrigin(request)
221+
return dedupeOrigins(requestOrigin ? [...validOrigins, ...devOrigins, requestOrigin] : [...validOrigins, ...devOrigins])
130222
}
131-
if (process.env.URL) {
132-
const inferredBaseURL = validateURL(process.env.URL.startsWith('http') ? process.env.URL : `https://${process.env.URL}`)
133-
logInferredBaseURL(inferredBaseURL, 'URL')
134-
return inferredBaseURL
223+
224+
if (typeof trustedOrigins === 'function') {
225+
return async (request?: Request) => {
226+
const resolvedOrigins = await trustedOrigins(request)
227+
return mergeOrigins(resolvedOrigins, request)
228+
}
135229
}
136230

137-
// 4. Dev fallback
138-
if (import.meta.dev) {
139-
const inferredBaseURL = 'http://localhost:3000'
140-
logInferredBaseURL(inferredBaseURL, 'development fallback')
141-
return inferredBaseURL
231+
if (Array.isArray(trustedOrigins)) {
232+
const baseOrigins = mergeOrigins(trustedOrigins)
233+
return async (request?: Request) => {
234+
return mergeOrigins(baseOrigins, request)
235+
}
142236
}
143237

144-
throw new Error('siteUrl required. Set NUXT_PUBLIC_SITE_URL.')
238+
return async (request?: Request) => {
239+
return mergeOrigins([], request)
240+
}
145241
}
146242

147243
/** Returns Better Auth instance. Caches per resolved host (or single instance when siteUrl is explicit). */
@@ -157,13 +253,15 @@ export function serverAuth(event?: H3Event): AuthInstance {
157253

158254
const database = createDatabase()
159255
const userConfig = createServerAuth({ runtimeConfig, db })
256+
const trustedOrigins = withDevTrustedOrigins(userConfig.trustedOrigins, Boolean(hasExplicitSiteUrl))
160257

161258
const auth = betterAuth({
162259
...userConfig,
163260
...(database && { database }),
164261
secondaryStorage: createSecondaryStorage(),
165262
secret: runtimeConfig.betterAuthSecret,
166263
baseURL: siteUrl,
264+
trustedOrigins,
167265
})
168266

169267
_authCache.set(cacheKey, auth)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
setups.@onmax/nuxt-better-auth="0.0.2-alpha.21"
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<template>
2+
<NuxtPage />
3+
</template>
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { defineClientAuth } from '../../../../src/runtime/config'
2+
3+
export default defineClientAuth({})

0 commit comments

Comments
 (0)