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
5 changes: 4 additions & 1 deletion docs/content/2.core-concepts/4.auto-imports-aliases.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ description: What the module registers for you.
Use auto-imported utilities from @onmax/nuxt-better-auth.

- Client (auto-imported in Vue): `useUserSession()`, `useSignIn()`, `useSignUp()`
- Server (auto-imported in `server/`): `serverAuth(event?)`, `getRequestSession(event)`, `getUserSession(event)`, `requireUserSession(event, options?)`
- Server (auto-imported in `server/`): `serverAuth(event?)`, `getRequestSession(event)`, `getUserSession(event)`, `setSessionCookie(event, token)`, `requireUserSession(event, options?)`
- Component: `<BetterAuthState>` for auth-ready rendering
- Alias `#nuxt-better-auth` exports types: `AuthUser`, `AuthSession`, `AuthSocialProviderId`
- Use `getRequestSession(event)` over repeated `getUserSession(event)` calls — it caches per request
Expand All @@ -33,6 +33,7 @@ Source: https://github.com/nuxt-modules/better-auth
- `serverAuth(event?)`
- `getRequestSession(event)`
- `getUserSession(event)`
- `setSessionCookie(event, token)`
- `requireUserSession(event, options?)`

**Components**
Expand All @@ -55,6 +56,8 @@ export default defineEventHandler(async (event) => {
Use `getRequestSession(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.

Use `setSessionCookie(event, token)` when a custom server handler completes its own verification step and needs to attach a Better Auth session cookie to the response.

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

```vue [pages/dashboard.vue]
Expand Down
21 changes: 21 additions & 0 deletions docs/content/5.api/2.server-utils.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,27 @@ export default defineServerAuth({
::note
This uses Better Auth's plugin API and does not require a module-specific option.
::

## setSessionCookie

Sets the Better Auth session token cookie on the current response. Use this helper in custom server-side authentication flows after you obtain or create a valid Better Auth session token.

```ts [server/api/custom-auth.post.ts]
export default defineEventHandler(async (event) => {
const token = await verifyCustomLoginAndReturnSessionToken(event)

await setSessionCookie(event, token)

return { ok: true }
})
```

The helper signs the cookie with your Better Auth secret and uses the configured cookie name and attributes from your server auth config.

::note
`setSessionCookie(event, token)` sets the session token cookie only. It does not try to recreate every internal Better Auth sign-in side effect.
::

## requireUserSession

Ensures the user is authenticated and optionally matches specific criteria. Throws a `401 Unauthorized` or `403 Forbidden` error if checks fail.
Expand Down
260 changes: 259 additions & 1 deletion src/runtime/server/utils/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,43 @@ import { matchesUser } from '../../utils/match-user'
import { serverAuth } from './auth'

const requestSessionLoadKey = Symbol.for('nuxt-better-auth.requestSessionLoad')
const signingAlgorithm: HmacImportParams = { name: 'HMAC', hash: 'SHA-256' }

interface CookieOptions {
domain?: string
expires?: Date
httpOnly?: boolean
maxAge?: number
path?: string
secure?: boolean
sameSite?: 'Strict' | 'Lax' | 'None' | 'strict' | 'lax' | 'none'
partitioned?: boolean
prefix?: 'host' | 'secure'
}

interface AuthCookie {
name: string
attributes: CookieOptions
}

interface ServerAuthContextLike {
authCookies: {
sessionToken: AuthCookie
sessionData: AuthCookie
dontRememberToken: AuthCookie
}
internalAdapter: {
createSession?: (userId: string, rememberMe: boolean) => Promise<unknown>
}
secret: string
sessionConfig: {
expiresIn: number
}
}

interface RequestSessionContext {
requestSession?: AppSession | null
requestHeaders?: Headers
[requestSessionLoadKey]?: Promise<AppSession | null>
}

Expand All @@ -26,9 +60,208 @@ function getRequestSessionContext(event: H3Event): RequestSessionContext {
return context
}

function getRequestHeaders(event: H3Event): Headers {
return getRequestSessionContext(event).requestHeaders ?? event.headers
}

function loadSession(event: H3Event): Promise<AppSession | null> {
const auth = serverAuth(event)
return auth.api.getSession({ headers: event.headers }) as Promise<AppSession | null>
return auth.api.getSession({ headers: getRequestHeaders(event) }) as Promise<AppSession | null>
}

function getServerAuthContext(event: H3Event): Promise<ServerAuthContextLike> {
const auth = serverAuth(event) as ReturnType<typeof serverAuth> & { $context: Promise<ServerAuthContextLike> }
return auth.$context
}

function getCookieName(name: string, prefix?: CookieOptions['prefix']): string | undefined {
if (prefix === 'secure')
return name.startsWith('__Secure-') ? name : `__Secure-${name}`

if (prefix === 'host')
return name.startsWith('__Host-') ? name : `__Host-${name}`

if (prefix)
return undefined

return name
}

function serializeCookieHeader(name: string, value: string, attributes: CookieOptions = {}, valueIsEncoded = false): string {
const cookieName = getCookieName(name, attributes.prefix)
if (!cookieName)
throw new Error(`Unsupported cookie prefix: ${attributes.prefix}`)

const cookieValue = valueIsEncoded ? value : encodeURIComponent(value)
const cookie = [`${cookieName}=${cookieValue}`]
const options = { ...attributes }

if (cookieName.startsWith('__Secure-') && !options.secure)
options.secure = true

if (cookieName.startsWith('__Host-')) {
options.secure = true
options.path = '/'
delete options.domain
}

if (typeof options.maxAge === 'number' && options.maxAge >= 0)
cookie.push(`Max-Age=${Math.floor(options.maxAge)}`)

if (options.domain && options.prefix !== 'host')
cookie.push(`Domain=${options.domain}`)

if (options.path)
cookie.push(`Path=${options.path}`)

if (options.expires)
cookie.push(`Expires=${options.expires.toUTCString()}`)

if (options.httpOnly)
cookie.push('HttpOnly')

if (options.partitioned)
options.secure = true

if (options.secure)
cookie.push('Secure')

if (options.sameSite) {
const normalizedSameSite = options.sameSite.charAt(0).toUpperCase() + options.sameSite.slice(1)
cookie.push(`SameSite=${normalizedSameSite}`)
}

if (options.partitioned)
cookie.push('Partitioned')

return cookie.join('; ')
}

function serializeCookie(name: string, value: string, attributes: CookieOptions = {}): string {
return serializeCookieHeader(name, value, attributes)
}

async function signCookieValue(value: string, secret: string): Promise<string> {
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
signingAlgorithm,
false,
['sign', 'verify'],
)
const signature = await crypto.subtle.sign(
signingAlgorithm.name,
key,
new TextEncoder().encode(value),
)

return encodeURIComponent(`${value}.${btoa(String.fromCharCode(...new Uint8Array(signature)))}`)
}

async function serializeSignedCookie(name: string, value: string, secret: string, attributes: CookieOptions = {}): Promise<string> {
return serializeCookieHeader(name, await signCookieValue(value, secret), attributes, true)
}

function appendCookieHeader(event: H3Event, header: string): void {
const nodeResponse = (event as H3Event & {
node?: {
res?: {
getHeader?: (name: string) => string | string[] | number | undefined
setHeader?: (name: string, value: string | string[]) => void
}
}
response?: {
headers?: Headers
}
}).node?.res

if (nodeResponse?.setHeader) {
const current = nodeResponse.getHeader?.('set-cookie')
if (Array.isArray(current))
nodeResponse.setHeader('set-cookie', [...current, header])
else if (typeof current === 'string')
nodeResponse.setHeader('set-cookie', [current, header])
else
nodeResponse.setHeader('set-cookie', [header])
return
}

const responseHeaders = (event as H3Event & { response?: { headers?: Headers } }).response?.headers
responseHeaders?.append('set-cookie', header)
}

function parseRequestCookies(cookieHeader: string | null): Map<string, string> {
const cookies = new Map<string, string>()
if (!cookieHeader)
return cookies

for (const pair of cookieHeader.split(/;\s*/)) {
if (!pair)
continue

const separatorIndex = pair.indexOf('=')
if (separatorIndex < 0)
continue

cookies.set(pair.slice(0, separatorIndex), pair.slice(separatorIndex + 1))
}

return cookies
}

function serializeRequestCookies(cookies: Map<string, string>): string | null {
if (!cookies.size)
return null

return Array.from(cookies.entries()).map(([name, value]) => `${name}=${value}`).join('; ')
}

function extractResponseCookieValue(header: string): string {
const separatorIndex = header.indexOf(';')
const cookiePair = separatorIndex >= 0 ? header.slice(0, separatorIndex) : header
return cookiePair.slice(cookiePair.indexOf('=') + 1)
}

function getChunkedCookieNames(event: H3Event, cookieName: string): string[] {
const cookieNames = new Set<string>([cookieName])
for (const name of parseRequestCookies(event.headers.get('cookie')).keys()) {
if (name.startsWith(`${cookieName}.`))
cookieNames.add(name)
}
return Array.from(cookieNames)
}

function expireCookie(event: H3Event, cookieName: string, attributes: CookieOptions): void {
appendCookieHeader(event, serializeCookie(cookieName, '', {
...attributes,
expires: new Date(0),
maxAge: 0,
}))
}

function expireCookies(event: H3Event, cookie: { name: string, attributes: CookieOptions }): void {
for (const cookieName of getChunkedCookieNames(event, cookie.name))
expireCookie(event, cookieName, cookie.attributes)
}

function updateRequestHeaders(event: H3Event, sessionCookie: string, clearedCookieNames: string[]): void {
const requestContext = getRequestSessionContext(event)
const requestHeaders = new Headers(event.headers)
const cookies = parseRequestCookies(event.headers.get('cookie'))

for (const name of clearedCookieNames)
cookies.delete(name)

const sessionTokenName = sessionCookie.slice(0, sessionCookie.indexOf('='))
cookies.set(sessionTokenName, extractResponseCookieValue(sessionCookie))

const nextCookieHeader = serializeRequestCookies(cookies)
if (nextCookieHeader)
requestHeaders.set('cookie', nextCookieHeader)
else
requestHeaders.delete('cookie')

requestContext.requestHeaders = requestHeaders
}

export async function getRequestSession(event: H3Event): Promise<AppSession | null> {
Expand Down Expand Up @@ -65,6 +298,31 @@ export async function getUserSession(event: H3Event): Promise<AppSession | null>
return loadSession(event)
}

export async function setSessionCookie(event: H3Event, token: string): Promise<void> {
const context = await getServerAuthContext(event)
const sessionCookie = await serializeSignedCookie(
context.authCookies.sessionToken.name,
token,
context.secret,
{
...context.authCookies.sessionToken.attributes,
maxAge: context.sessionConfig.expiresIn,
},
)

appendCookieHeader(event, sessionCookie)
expireCookies(event, context.authCookies.sessionData)
expireCookies(event, context.authCookies.dontRememberToken)

const requestContext = getRequestSessionContext(event)
delete requestContext.requestSession
delete requestContext[requestSessionLoadKey]
updateRequestHeaders(event, sessionCookie, [
...getChunkedCookieNames(event, context.authCookies.sessionData.name),
...getChunkedCookieNames(event, context.authCookies.dontRememberToken.name),
])
}

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

Expand Down
Loading
Loading