Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/content/1.getting-started/2.configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,10 +144,12 @@ The module resolves `siteUrl` using this priority:
| Priority | Source | When Used |
|----------|--------|-----------|
| 1 | `runtimeConfig.public.siteUrl` | Explicit config (always wins) |
| 2 | Request URL | Auto-detected from HTTP request |
| 2 | Request URL | Auto-detected from the current Nitro request (`event`) |
| 3 | `VERCEL_URL`, `CF_PAGES_URL`, `URL` | Platform env vars (Vercel, Cloudflare, Netlify) |
| 4 | `http://localhost:3000` | Development only |

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.

Set an explicit site URL in `nuxt.config.ts` for deterministic OAuth callbacks and origin checks:

```ts [nuxt.config.ts]
Expand Down
2 changes: 1 addition & 1 deletion docs/content/1.getting-started/6.how-it-works.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ On the client side, `useUserSession` initializes the Better Auth client. It esta
|---------|------------------|------|
| Server handler | Mounts Better Auth at `/api/auth/*` | Server |
| Composables | `useUserSession()` for reactive state | Client |
| Server utils | `serverAuth()`, `getUserSession(event)`, etc. | Server |
| Server utils | `serverAuth(event?)`, `getUserSession(event)`, etc. | Server |
| Route protection | `routeRules.auth` and `definePageMeta({ auth })` | Both |
| Type augmentation | Inferred `AuthUser` and `AuthSession` types | Build |

Expand Down
2 changes: 1 addition & 1 deletion docs/content/2.core-concepts/1.server-auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ title: serverAuth()
description: When to reach for the full Better Auth server instance.
---

`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.
`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.

## When to Use What

Expand Down
4 changes: 2 additions & 2 deletions docs/content/2.core-concepts/4.auto-imports-aliases.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ description: What the module registers for you.

**Server**

- `serverAuth()`
- `serverAuth(event?)`
- `getUserSession(event)`
- `requireUserSession(event, options?)`

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

Expand Down
3 changes: 1 addition & 2 deletions docs/content/5.api/2.server-utils.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ await auth.api.getSession({
```

::note
Use `serverAuth()` to access advanced Better Auth features not exposed by the helper wrappers.
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.
::

## getUserSession
Expand Down Expand Up @@ -112,4 +112,3 @@ export default defineEventHandler(async (event) => {
return getTeamSettings(teamId)
})
```

2 changes: 2 additions & 0 deletions docs/content/6.troubleshooting/1.faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ Restart dev and inspect [http://localhost:3000/api/_better-auth/config](http://l

- Verify `config.server.baseURL` matches your local origin exactly.
- Verify `config.server.trustedOrigins` includes your local origin.

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.
::

::important
Expand Down
166 changes: 132 additions & 34 deletions src/runtime/server/utils/auth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Auth } from 'better-auth'
import type { Auth, BetterAuthOptions } from 'better-auth'
import type { H3Event } from 'h3'
import { createDatabase, db } from '#auth/database'
import { createSecondaryStorage } from '#auth/secondary-storage'
Expand Down Expand Up @@ -48,12 +48,36 @@ function validateURL(url: string): string {
}
}

function resolveConfiguredSiteUrl(config: ReturnType<typeof useRuntimeConfig>): string | undefined {
if (typeof config.public.siteUrl !== 'string' || !config.public.siteUrl)
return undefined

return validateURL(config.public.siteUrl)
}

function resolveEventOrigin(event?: H3Event): string | undefined {
if (!event)
return undefined

const host = getRequestHost(event, { xForwardedHost: true })
const protocol = getRequestProtocol(event, { xForwardedProto: true })
if (!host || !protocol)
return undefined

try {
return validateURL(`${protocol}://${host}`)
}
catch {
return undefined
}
}

/**
* Get the Nitro origin URL.
* Adapted from nuxt-site-config by @harlan-zw
* @see https://github.com/harlan-zw/nuxt-site-config/blob/main/packages/kit/src/util.ts
*/
function getNitroOrigin(e?: H3Event): string | undefined {
function getNitroOrigin(): string | undefined {
const cert = process.env.NITRO_SSL_CERT
const key = process.env.NITRO_SSL_KEY
let host: string | undefined = process.env.NITRO_HOST || process.env.HOST
Expand All @@ -73,10 +97,6 @@ function getNitroOrigin(e?: H3Event): string | undefined {
host = withoutProtocol(origin)
protocol = origin.includes('https') ? 'https' : 'http'
}
else if (e) {
host = getRequestHost(e, { xForwardedHost: true }) || host
protocol = getRequestProtocol(e, { xForwardedProto: true }) || protocol
}
}
catch {
// JSON parse failed, continue with env fallbacks
Expand All @@ -102,46 +122,122 @@ function getNitroOrigin(e?: H3Event): string | undefined {
return `${protocol}://${host}${portSuffix}`
}

function resolveEnvironmentOrigin(): { origin: string, source: string } | undefined {
const nitroOrigin = getNitroOrigin()
if (nitroOrigin)
return { origin: validateURL(nitroOrigin), source: 'Nitro environment detection' }

if (process.env.VERCEL_URL)
return { origin: validateURL(`https://${process.env.VERCEL_URL}`), source: 'VERCEL_URL' }

if (process.env.CF_PAGES_URL)
return { origin: validateURL(`https://${process.env.CF_PAGES_URL}`), source: 'CF_PAGES_URL' }

if (process.env.URL)
return { origin: validateURL(process.env.URL.startsWith('http') ? process.env.URL : `https://${process.env.URL}`), source: 'URL' }

return undefined
}

function resolveDevFallback(): { origin: string, source: string } | undefined {
if (!import.meta.dev)
return undefined

return { origin: 'http://localhost:3000', source: 'development fallback' }
}

function getBaseURL(event?: H3Event): string {
const config = useRuntimeConfig()
const configuredSiteUrl = resolveConfiguredSiteUrl(config)
if (configuredSiteUrl)
return configuredSiteUrl

const eventOrigin = resolveEventOrigin(event)
if (eventOrigin) {
logInferredBaseURL(eventOrigin, 'request origin')
return eventOrigin
}

const environmentOrigin = resolveEnvironmentOrigin()
if (environmentOrigin) {
logInferredBaseURL(environmentOrigin.origin, environmentOrigin.source)
return environmentOrigin.origin
}

const devFallback = resolveDevFallback()
if (devFallback) {
logInferredBaseURL(devFallback.origin, devFallback.source)
return devFallback.origin
}

throw new Error('siteUrl required. Set NUXT_PUBLIC_SITE_URL.')
}

function dedupeOrigins(origins: readonly string[]): string[] {
return [...new Set(origins)]
}

// 1. Explicit config (highest priority)
if (config.public.siteUrl && typeof config.public.siteUrl === 'string')
return validateURL(config.public.siteUrl)
function getDevTrustedOrigins(): string[] {
const fallbackOrigin = 'http://localhost:3000'
const nitroOrigin = getNitroOrigin()
if (!nitroOrigin)
return [fallbackOrigin]

// 2. Nitro origin detection (handles dev proxy, request headers)
const nitroOrigin = getNitroOrigin(event)
if (nitroOrigin) {
const inferredBaseURL = validateURL(nitroOrigin)
logInferredBaseURL(inferredBaseURL, 'Nitro/request origin detection')
return inferredBaseURL
try {
const url = new URL(nitroOrigin)
const protocol = url.protocol === 'https:' ? 'https' : 'http'
const port = url.port || '3000'
const localhostOrigin = `${protocol}://localhost:${port}`
return dedupeOrigins([localhostOrigin, url.origin])
}
catch {
return [fallbackOrigin]
}
}

// 3. Platform env vars (fallback for non-request contexts)
if (process.env.VERCEL_URL) {
const inferredBaseURL = validateURL(`https://${process.env.VERCEL_URL}`)
logInferredBaseURL(inferredBaseURL, 'VERCEL_URL')
return inferredBaseURL
function getRequestOrigin(request?: Request): string | undefined {
if (!request)
return undefined

try {
return new URL(request.url).origin
}
if (process.env.CF_PAGES_URL) {
const inferredBaseURL = validateURL(`https://${process.env.CF_PAGES_URL}`)
logInferredBaseURL(inferredBaseURL, 'CF_PAGES_URL')
return inferredBaseURL
catch {
return undefined
}
}

function withDevTrustedOrigins(
trustedOrigins: BetterAuthOptions['trustedOrigins'] | undefined,
hasExplicitSiteUrl: boolean,
): BetterAuthOptions['trustedOrigins'] | undefined {
if (!import.meta.dev || !hasExplicitSiteUrl)
return trustedOrigins

const devOrigins = getDevTrustedOrigins()
const mergeOrigins = (origins: readonly (string | null | undefined)[], request?: Request): string[] => {
const validOrigins = origins.filter((origin): origin is string => typeof origin === 'string')
const requestOrigin = getRequestOrigin(request)
return dedupeOrigins(requestOrigin ? [...validOrigins, ...devOrigins, requestOrigin] : [...validOrigins, ...devOrigins])
}
if (process.env.URL) {
const inferredBaseURL = validateURL(process.env.URL.startsWith('http') ? process.env.URL : `https://${process.env.URL}`)
logInferredBaseURL(inferredBaseURL, 'URL')
return inferredBaseURL

if (typeof trustedOrigins === 'function') {
return async (request?: Request) => {
const resolvedOrigins = await trustedOrigins(request)
return mergeOrigins(resolvedOrigins, request)
}
}

// 4. Dev fallback
if (import.meta.dev) {
const inferredBaseURL = 'http://localhost:3000'
logInferredBaseURL(inferredBaseURL, 'development fallback')
return inferredBaseURL
if (Array.isArray(trustedOrigins)) {
const baseOrigins = mergeOrigins(trustedOrigins)
return async (request?: Request) => {
return mergeOrigins(baseOrigins, request)
}
}

throw new Error('siteUrl required. Set NUXT_PUBLIC_SITE_URL.')
return async (request?: Request) => {
return mergeOrigins([], request)
}
}

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

const database = createDatabase()
const userConfig = createServerAuth({ runtimeConfig, db })
const trustedOrigins = withDevTrustedOrigins(userConfig.trustedOrigins, Boolean(hasExplicitSiteUrl))

const auth = betterAuth({
...userConfig,
...(database && { database }),
secondaryStorage: createSecondaryStorage(),
secret: runtimeConfig.betterAuthSecret,
baseURL: siteUrl,
trustedOrigins,
})

_authCache.set(cacheKey, auth)
Expand Down
1 change: 1 addition & 0 deletions test/cases/dev-trusted-origins/.nuxtrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
setups.@onmax/nuxt-better-auth="0.0.2-alpha.21"
3 changes: 3 additions & 0 deletions test/cases/dev-trusted-origins/app/app.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<template>
<NuxtPage />
</template>
3 changes: 3 additions & 0 deletions test/cases/dev-trusted-origins/app/auth.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { defineClientAuth } from '../../../../src/runtime/config'

export default defineClientAuth({})
3 changes: 3 additions & 0 deletions test/cases/dev-trusted-origins/app/pages/index.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<template>
<div>dev trusted origins</div>
</template>
9 changes: 9 additions & 0 deletions test/cases/dev-trusted-origins/nuxt.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export default defineNuxtConfig({
modules: ['../../../src/module'],
runtimeConfig: {
betterAuthSecret: 'test-secret-for-testing-only-32chars!',
public: {
siteUrl: 'https://foo.workers.dev',
},
},
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export default defineEventHandler(async (event) => {
const auth = serverAuth(event) as {
$context?: Promise<{ trustedOrigins?: string[] }> | { trustedOrigins?: string[] }
}
const authContext = await auth.$context

return {
trustedOrigins: authContext?.trustedOrigins || [],
}
})
5 changes: 5 additions & 0 deletions test/cases/dev-trusted-origins/server/auth.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { defineServerAuth } from '../../../../src/runtime/config'

export default defineServerAuth({
appName: 'Dev trusted origins test app',
})
24 changes: 24 additions & 0 deletions test/dev-trusted-origins.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { fileURLToPath } from 'node:url'
import { setup, url } from '@nuxt/test-utils/e2e'
import { describe, expect, it } from 'vitest'

describe('serverAuth trustedOrigins in dev with explicit siteUrl', async () => {
await setup({
rootDir: fileURLToPath(new URL('./cases/dev-trusted-origins', import.meta.url)),
dev: true,
})

it('includes explicit production origin and current dev request origin', async () => {
const response = await fetch(url('/api/test/trusted-origins'))
expect(response.status).toBe(200)
const body = await response.json() as { trustedOrigins: string[] }

const testServerUrl = new URL(url('/'))
const expectedLocalhostOrigin = `${testServerUrl.protocol}//localhost:${testServerUrl.port}`
const expectedRequestOrigin = testServerUrl.origin

expect(body.trustedOrigins).toContain('https://foo.workers.dev')
expect(body.trustedOrigins).toContain(expectedLocalhostOrigin)
expect(body.trustedOrigins).toContain(expectedRequestOrigin)
})
})
Loading
Loading