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
6 changes: 6 additions & 0 deletions docs/content/1.getting-started/2.configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,12 @@ export default defineServerAuth((ctx) => ({
}))
```

### Session Enrichment

You can enrich session payloads with Better Auth's `custom-session` plugin through `plugins` in `defineServerAuth`. This module does not provide a separate `appSession.enrich` option.

See the full recipe in [Server Utilities](/api/server-utils#session-enrichment-with-custom-session).

## Base URL Configuration

The module resolves `siteUrl` using this priority:
Expand Down
1 change: 1 addition & 0 deletions docs/content/2.core-concepts/1.server-auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ description: When to reach for the full Better Auth server instance.

| Task | Use | Example |
|------|-----|---------|
| Get request-cached session context | `getAppSession(event)` | Cache once per request with context-backed storage when available |
| Get current session | `getUserSession(event)` | Check if user is logged in |
| Require authentication | `requireUserSession(event)` | Protect an API route |
| Access Better Auth API | `serverAuth(event)` | Call `auth.api.listSessions()` |
Expand Down
5 changes: 5 additions & 0 deletions docs/content/2.core-concepts/4.auto-imports-aliases.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ description: What the module registers for you.
**Server**

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

Expand All @@ -26,11 +27,15 @@ description: What the module registers for you.
```ts [server/api/profile.ts]
export default defineEventHandler(async (event) => {
// These are auto-imported, no import statement needed
const appSession = await getAppSession(event)
const session = await getUserSession(event)
const auth = serverAuth(event)
})
```

Use `getAppSession(event)` when multiple handlers or middleware in the same request need session data. The helper should be preferred over repeated `getUserSession(event)` calls in the same request chain.
`getUserSession(event)` does not memoize by itself.

### Client Composables (auto-imported in Vue components)

```vue [pages/dashboard.vue]
Expand Down
10 changes: 10 additions & 0 deletions docs/content/4.integrations/1.nuxthub.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,16 @@ export default defineNuxtConfig({

Sessions are cached in NuxtHub KV, reducing database queries.

## Choosing Secondary Storage

`secondaryStorage` stores session lookups in NuxtHub KV and can reduce repeated database reads on session validation. SSR requests still resolve session state per request, but lookups can hit KV instead of the database.

This setup can introduce a short propagation window after session invalidation events, such as sign-out or permission-sensitive updates. For critical authorization paths, keep server-side checks in place with `requireUserSession`.

Enable `secondaryStorage` when `hub.kv` is enabled and session-read traffic is a measurable bottleneck. Keep DB-only sessions when your traffic is moderate or when you prioritize stricter read-after-write consistency.

To return to DB-only session reads, set `auth.secondaryStorage` to `false` and deploy.

## Production Migrations

NuxtHub handles migrations automatically in most deployments. For manual control:
Expand Down
47 changes: 47 additions & 0 deletions docs/content/5.api/2.server-utils.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,57 @@ export default defineEventHandler(async (event) => {
})
```

`getUserSession` does not memoize by itself. Use `getAppSession(event)` when you need request-lifetime caching across middleware/handlers.

**Returns:**
- `{ user: AuthUser, session: AuthSession }` if authenticated.
- `null` if unauthenticated.

## getAppSession

Retrieves the current session and memoizes it for the lifetime of the current request. In Nuxt and Nitro handlers, the helper stores the memoized value on `event.context.appSession`.

```ts [server/api/example.ts]
export default defineEventHandler(async (event) => {
const appSession = await getAppSession(event)
const sameRequestSession = await getAppSession(event) // cached
})
```

Use this helper when multiple handlers/middleware in the same request need session access without repeating `auth.api.getSession()` calls.

::note
`getAppSession` stays type-compatible in projects that use narrowed `h3` typings where `H3Event` does not explicitly declare `context`.
::

## Session Enrichment with `custom-session`

Use Better Auth's `custom-session` plugin when your app needs computed fields in the session payload returned by server helpers. Define the enrichment in `server/auth.config.ts`, and `getUserSession` or `getAppSession` will return the enriched shape.

```ts [server/auth.config.ts]
import { customSession } from 'better-auth/plugins'
import { defineServerAuth } from '@onmax/nuxt-better-auth/config'

export default defineServerAuth({
plugins: [
customSession(async ({ user, session }) => {
return {
user: {
...user,
role: user.email?.endsWith('@company.com') ? 'member' : 'guest',
},
session: {
...session,
},
}
}),
],
})
```

::note
This uses Better Auth's plugin API and does not require a module-specific option.
::
## requireUserSession

Ensures the user is authenticated and optionally matches specific criteria. Throws a `401 Unauthorized` or `403 Forbidden` error if checks fail.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"typecheck": "vue-tsc --noEmit",
"typecheck:runtime-server": "tsc --noEmit --pretty false -p src/runtime/server/tsconfig.json",
"typecheck:playground": "cd playground && vue-tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest watch"
Expand Down
2 changes: 1 addition & 1 deletion src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,4 +226,4 @@ export default defineNuxtModule<BetterAuthModuleOptions>({
})

export { defineClientAuth, defineServerAuth } from './runtime/config'
export type { Auth, AuthMeta, AuthMode, AuthRouteRules, AuthSession, AuthUser, InferSession, InferUser, RequireSessionOptions, ServerAuthContext, UserMatch } from './runtime/types'
export type { AppSession, Auth, AuthMeta, AuthMode, AuthRouteRules, AuthSession, AuthUser, InferSession, InferUser, RequireSessionOptions, ServerAuthContext, UserMatch } from './runtime/types'
2 changes: 1 addition & 1 deletion src/module/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export function setupRuntimeConfig(input: SetupRuntimeConfigInput): { secondaryS
const siteUrl = nuxt.options.runtimeConfig.public.siteUrl as string | undefined
if (!siteUrl)
consola.warn('clientOnly mode: set runtimeConfig.public.siteUrl (or NUXT_PUBLIC_SITE_URL) to your frontend URL')
consola.info('clientOnly mode enabled - server utilities (serverAuth, getUserSession, requireUserSession) are not available')
consola.info('clientOnly mode enabled - server utilities (serverAuth, getAppSession, getUserSession, requireUserSession) are not available')
return { secondaryStorageEnabled }
}

Expand Down
12 changes: 6 additions & 6 deletions src/module/type-templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,13 +126,13 @@ export function registerSharedTypeTemplates(input: RegisterSharedTypeTemplatesIn
addTypeTemplate({
filename: 'types/nuxt-better-auth.d.ts',
getContents: () => `
import type { AuthSession, AuthUser } from '${input.runtimeTypesAugmentPath}'
import type { UserMatch } from '${input.runtimeTypesPath}'
import type { AppSession } from '${input.runtimeTypesAugmentPath}'
export * from '${input.runtimeTypesAugmentPath}'
export type { AuthMeta, AuthMode, AuthRouteRules, UserMatch, Auth, InferUser, InferSession } from '${input.runtimeTypesPath}'
export interface RequireSessionOptions {
user?: UserMatch<AuthUser>
rule?: (ctx: { user: AuthUser, session: AuthSession }) => boolean | Promise<boolean>
export type { AuthMeta, AuthMode, AuthRouteRules, Auth, InferUser, InferSession } from '${input.runtimeTypesPath}'
declare module 'h3' {
interface H3EventContext {
appSession?: AppSession | null
}
}
`,
})
Expand Down
7 changes: 5 additions & 2 deletions src/runtime/server/api/_better-auth/sessions.get.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type { Session } from 'better-auth/types'
import { defineEventHandler, getQuery } from 'h3'
import { paginationQuerySchema, sanitizeSearchPattern } from './_schema'

type SafeSession = Pick<Session, 'id' | 'userId' | 'createdAt' | 'updatedAt' | 'expiresAt' | 'ipAddress' | 'userAgent'>

export default defineEventHandler(async (event) => {
try {
const { db, schema } = await import('@nuxthub/db')
Expand Down Expand Up @@ -34,11 +37,11 @@ export default defineEventHandler(async (event) => {
])

// Return only safe fields (allowlist approach for security)
const safeSessions = sessions.map(s => ({
const safeSessions: SafeSession[] = sessions.map((s: Session) => ({
id: s.id,
userId: s.userId,
createdAt: s.createdAt,
updatedAt: (s as Record<string, unknown>).updatedAt,
updatedAt: s.updatedAt,
expiresAt: s.expiresAt,
ipAddress: s.ipAddress,
userAgent: s.userAgent,
Expand Down
1 change: 1 addition & 0 deletions src/runtime/server/middleware/route-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { AuthMeta, AuthMode, AuthRouteRules } from '../../types'
import { createError, defineEventHandler, getRequestURL } from 'h3'
import { getRouteRules } from 'nitropack/runtime'
import { matchesUser } from '../../utils/match-user'
import { getUserSession, requireUserSession } from '../utils/session'

export default defineEventHandler(async (event) => {
const path = getRequestURL(event).pathname
Expand Down
10 changes: 9 additions & 1 deletion src/runtime/server/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
{
"extends": "../../../.nuxt/tsconfig.server.json"
"extends": "../../../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"#nuxt-better-auth": ["../types/augment"]
}
},
"include": ["./**/*.ts", "./**/*.d.ts"],
"exclude": ["node_modules"]
}
68 changes: 58 additions & 10 deletions src/runtime/server/utils/session.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,72 @@
import type { AuthSession, AuthUser } from '#nuxt-better-auth'
import type { AppSession, RequireSessionOptions } from '#nuxt-better-auth'
import type { H3Event } from 'h3'
import type { UserMatch } from '../../types'
import { createError } from 'h3'
import { matchesUser } from '../../utils/match-user'
import { serverAuth } from './auth'

interface FullSession { user: AuthUser, session: AuthSession }
interface RequireUserSessionOptions {
user?: UserMatch<AuthUser>
rule?: (ctx: { user: AuthUser, session: AuthSession }) => boolean | Promise<boolean>
const appSessionLoadKey = Symbol.for('nuxt-better-auth.appSessionLoad')

interface AppSessionContext {
appSession?: AppSession | null
[appSessionLoadKey]?: Promise<AppSession | null>
}

const fallbackAppSessionContext = new WeakMap<object, AppSessionContext>()

function getAppSessionContext(event: H3Event): AppSessionContext {
const eventWithContext = event as H3Event & { context?: unknown }
if (eventWithContext.context && typeof eventWithContext.context === 'object')
return eventWithContext.context as AppSessionContext

let context = fallbackAppSessionContext.get(event as object)
if (!context) {
context = {}
fallbackAppSessionContext.set(event as object, context)
}
return context
}

export async function getUserSession(event: H3Event): Promise<FullSession | null> {
export async function getAppSession(event: H3Event): Promise<AppSession | null> {
const context = getAppSessionContext(event)
if (context.appSession !== undefined)
return context.appSession

if (context[appSessionLoadKey])
return context[appSessionLoadKey]

const load = (async () => {
const auth = serverAuth(event)
const session = await auth.api.getSession({ headers: event.headers })
return session as AppSession | null
})()

context[appSessionLoadKey] = load
try {
const session = await load
context.appSession = session
return session
}
finally {
delete context[appSessionLoadKey]
}
}

export async function getUserSession(event: H3Event): Promise<AppSession | null> {
// Preserve historical behavior: don't memoize, but reuse request cache if present.
const context = getAppSessionContext(event)
if (context.appSession !== undefined)
return context.appSession

if (context[appSessionLoadKey])
return context[appSessionLoadKey]

const auth = serverAuth(event)
const session = await auth.api.getSession({ headers: event.headers })
return session as FullSession | null
return session as AppSession | null
}

export async function requireUserSession(event: H3Event, options?: RequireUserSessionOptions): Promise<FullSession> {
const session = await getUserSession(event)
export async function requireUserSession(event: H3Event, options?: RequireSessionOptions): Promise<AppSession> {
const session = await getAppSession(event)

if (!session)
throw createError({ statusCode: 401, statusMessage: 'Authentication required' })
Expand Down
18 changes: 18 additions & 0 deletions src/runtime/server/virtual-modules.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
declare module '#auth/database' {
export const db: any
export function createDatabase(...args: any[]): any
}

declare module '#auth/secondary-storage' {
export function createSecondaryStorage(...args: any[]): any
}

declare module '#auth/server' {
const createServerAuth: any
export default createServerAuth
}

declare module '@nuxthub/db' {
export const db: any
export const schema: any
}
13 changes: 2 additions & 11 deletions src/runtime/types.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,14 @@
import type { NitroRouteRules } from 'nitropack/types'
import type { AuthSession, AuthUser } from './types/augment'
import type { AuthUser, UserMatch } from './types/augment'

// Re-export augmentable types
export type { AuthSession, AuthUser, ServerAuthContext, UserSessionComposable } from './types/augment'
export type { AppSession, AuthSession, AuthUser, RequireSessionOptions, ServerAuthContext, UserMatch, UserSessionComposable } from './types/augment'

// Re-export better-auth types for $Infer access
export type { Auth, InferPluginTypes, InferSessionFromClient as InferSession, InferUserFromClient as InferUser } from 'better-auth'

export type AuthMode = 'guest' | 'user'

// Flexible matching - value OR array of values (OR logic for same field, AND logic between fields)
export type UserMatch<T> = { [K in keyof T]?: T[K] | T[K][] }

// Route auth meta
export type AuthMeta = false | AuthMode | {
only?: AuthMode
Expand All @@ -21,9 +18,3 @@ export type AuthMeta = false | AuthMode | {

// Route rules with auth
export type AuthRouteRules = NitroRouteRules & { auth?: AuthMeta }

// Helper type for requireUserSession options
export interface RequireSessionOptions {
user?: UserMatch<AuthUser>
rule?: (ctx: { user: AuthUser, session: AuthSession }) => boolean | Promise<boolean>
}
12 changes: 12 additions & 0 deletions src/runtime/types/augment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,15 @@ export interface UserSessionComposable {
signOut: (options?: { onSuccess?: () => void | Promise<void> }) => Promise<void>
updateUser: (updates: Partial<AuthUser>) => Promise<void>
}

export type UserMatch<T> = { [K in keyof T]?: T[K] | T[K][] }

export interface AppSession {
user: AuthUser
session: AuthSession
}

export interface RequireSessionOptions {
user?: UserMatch<AuthUser>
rule?: (ctx: { user: AuthUser, session: AuthSession }) => boolean | Promise<boolean>
}
2 changes: 1 addition & 1 deletion test/cases/base-url-inference/.nuxtrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
setups.@onmax/nuxt-better-auth="0.0.2-alpha.21"
setups.@onmax/nuxt-better-auth="0.0.2-alpha.23"
2 changes: 1 addition & 1 deletion test/cases/core-auth/.nuxtrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
setups.@onmax/nuxt-better-auth="0.0.2-alpha.21"
setups.@onmax/nuxt-better-auth="0.0.2-alpha.23"
2 changes: 1 addition & 1 deletion test/cases/database-less/.nuxtrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
setups.@onmax/nuxt-better-auth="0.0.2-alpha.21"
setups.@onmax/nuxt-better-auth="0.0.2-alpha.23"
2 changes: 1 addition & 1 deletion test/cases/dev-trusted-origins/.nuxtrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
setups.@onmax/nuxt-better-auth="0.0.2-alpha.21"
setups.@onmax/nuxt-better-auth="0.0.2-alpha.23"
2 changes: 1 addition & 1 deletion test/cases/plugins-type-inference/.nuxtrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
setups.@onmax/nuxt-better-auth="0.0.2-alpha.21"
setups.@onmax/nuxt-better-auth="0.0.2-alpha.23"
2 changes: 1 addition & 1 deletion test/cases/without-nuxthub/.nuxtrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
setups.@onmax/nuxt-better-auth="0.0.2-alpha.21"
setups.@onmax/nuxt-better-auth="0.0.2-alpha.23"
2 changes: 1 addition & 1 deletion test/define-server-auth-literal-inference.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest'
const fixtureDir = fileURLToPath(new URL('./cases/define-server-auth-literal-inference', import.meta.url))

describe('defineServerAuth literal inference regression #134', () => {
it('typechecks nested literals without as const and rejects invalid literals', () => {
it('typechecks nested literals without as const and rejects invalid literals', { timeout: 30_000 }, () => {
const typecheck = spawnSync('pnpm', ['exec', 'tsc', '--noEmit', '--pretty', 'false', '-p', 'tsconfig.json'], {
cwd: fixtureDir,
encoding: 'utf8',
Expand Down
Loading
Loading