diff --git a/docs/content/2.core-concepts/1.server-auth.md b/docs/content/2.core-concepts/1.server-auth.md index f037ed1d..ca018395 100644 --- a/docs/content/2.core-concepts/1.server-auth.md +++ b/docs/content/2.core-concepts/1.server-auth.md @@ -12,6 +12,7 @@ Use server-side auth utilities in @nuxtjs/better-auth. - `getUserSession(event)` — get current session (auto-imported in `server/`) - `requireUserSession(event, options?)` — throws 401 if not authenticated, supports role matching - `getRequestSession(event)` — request-cached session, preferred over repeated `getUserSession` in same request +- `setRequestSession(event, session)` — supply an authenticated session to downstream helpers for the current request - `refreshSessionCookieCache(event)` — refresh Better Auth's cached session cookie after server-side session data changes - All server utils are auto-imported, no import statements needed ``` @@ -27,6 +28,7 @@ Use this page when you need authentication state or Better Auth APIs inside Nitr | Task | Use | Example | |------|-----|---------| | Get request-cached session context | `getRequestSession(event)` | Cache once per request with context-backed storage when available | +| Supply a request session | `setRequestSession(event, session)` | Reuse a session resolved by trusted server authentication | | Get current session | `getUserSession(event)` | Check if user is logged in | | Refresh cached session cookie | `refreshSessionCookieCache(event)` | Use after updating data returned by Better Auth session helpers | | Require authentication | `requireUserSession(event)` | Protect an API route | diff --git a/docs/content/2.core-concepts/4.auto-imports-aliases.md b/docs/content/2.core-concepts/4.auto-imports-aliases.md index 2bb15e02..fce20974 100644 --- a/docs/content/2.core-concepts/4.auto-imports-aliases.md +++ b/docs/content/2.core-concepts/4.auto-imports-aliases.md @@ -9,7 +9,7 @@ description: What the module registers for you. Use auto-imported utilities from @nuxtjs/better-auth. - Client (auto-imported in Vue): `useUserSession()`, `useUserSessionState()`, `useAuthClient()`, `useSignIn()`, `useSignUp()`, `useAuthClientAction()`, `runWithSessionRefresh()` -- Server (auto-imported in `server/`): `serverAuth(event?)`, `getRequestSession(event)`, `getUserSession(event)`, `refreshSessionCookieCache(event)`, `createSession(event, userId)`, `setSessionCookie(event, token)`, `requireUserSession(event, options?)` +- Server (auto-imported in `server/`): `serverAuth(event?)`, `getRequestSession(event)`, `setRequestSession(event, session)`, `getUserSession(event)`, `refreshSessionCookieCache(event)`, `createSession(event, userId)`, `setSessionCookie(event, token)`, `requireUserSession(event, options?)` - Component: `` 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 @@ -35,6 +35,7 @@ Use this page when you want a quick inventory of what the module registers for y - `serverAuth(event?)` - `getRequestSession(event)` +- `setRequestSession(event, session)` - `getUserSession(event)` - `refreshSessionCookieCache(event)` - `createSession(event, userId)` @@ -59,6 +60,7 @@ 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. +Use `setRequestSession(event, session)` after trusted server code authenticates a request and resolves a complete `AppSession` that downstream session helpers should reuse. The value applies only to the current request and does not set a session cookie. `getUserSession(event)` does not memoize by itself. Use `refreshSessionCookieCache(event)` after server-side code updates data returned by the Better Auth session. This refreshes the cached session cookie. It does not update the session or user record; perform that update first. diff --git a/docs/content/5.api/2.server-utils.md b/docs/content/5.api/2.server-utils.md index 441c2003..d216eef4 100644 --- a/docs/content/5.api/2.server-utils.md +++ b/docs/content/5.api/2.server-utils.md @@ -76,6 +76,27 @@ Use this helper when multiple handlers/middleware in the same request need sessi `getRequestSession` stays type-compatible in projects that use narrowed `h3` typings where `H3Event` does not explicitly declare `context`. :: +## setRequestSession + +Supplies an `AppSession | null` to `getRequestSession`, `getUserSession`, and `requireUserSession` for the rest of the current request. Use it when trusted server code authenticates a request through another mechanism, such as a verified bearer token, and resolves the complete application session itself. + +```ts [server/middleware/bearer-session.ts] +export default defineEventHandler(async (event) => { + const claims = await verifyBearerToken(event) + const session = await resolveCurrentAppSession(claims) + + setRequestSession(event, session) + + // Later middleware and handlers reuse the supplied value. +}) +``` + +The helper makes the supplied value authoritative immediately, so an older session lookup or refresh cannot overwrite it when that work settles. Pass `null` to cache an unauthenticated result for the current request. + +::warning +`setRequestSession` trusts the supplied value. Authenticate the request and enforce bearer-token audience and scope restrictions before calling it. The helper does not create, update, or clear a Better Auth session cookie. +:: + ## refreshSessionCookieCache Refreshes Better Auth's cached session cookie on the current response, then refreshes the request-cached session used by `getRequestSession(event)`. diff --git a/docs/public/.well-known/skills/nuxt-better-auth/references/server-auth.md b/docs/public/.well-known/skills/nuxt-better-auth/references/server-auth.md index 44024ddb..9310e93e 100644 --- a/docs/public/.well-known/skills/nuxt-better-auth/references/server-auth.md +++ b/docs/public/.well-known/skills/nuxt-better-auth/references/server-auth.md @@ -7,6 +7,7 @@ These helpers are auto-imported inside `server/` in full mode: - `serverAuth(event?)` - `getUserSession(event)` - `getRequestSession(event)` +- `setRequestSession(event, session)` - `refreshSessionCookieCache(event)` - `requireUserSession(event, options?)` - `createSession(event, userId)` @@ -19,6 +20,7 @@ These helpers are auto-imported inside `server/` in full mode: | Access raw Better Auth APIs | `serverAuth(event)` | | Read session if it exists | `getUserSession(event)` | | Reuse the same session lookup in one request | `getRequestSession(event)` | +| Supply a session resolved by trusted server authentication | `setRequestSession(event, session)` | | Refresh Better Auth's cached session cookie after server-side updates | `refreshSessionCookieCache(event)` | | Enforce auth | `requireUserSession(event, options?)` | | Create a session in a custom flow | `createSession(event, userId)` | @@ -38,6 +40,20 @@ export default defineEventHandler(async (event) => { `requireUserSession(event)` throws `401` when unauthenticated and `403` when the user match or custom rule fails. +## Supply a verified request session + +Use `setRequestSession(event, session)` when another server authentication layer verifies the request and resolves a complete `AppSession` for existing session helpers to reuse. + +```ts +const claims = await verifyBearerToken(event) +const session = await resolveCurrentAppSession(claims) + +setRequestSession(event, session) +await requireUserSession(event) +``` + +The supplied value applies only to the current request and does not set a session cookie. Authenticate the value and enforce bearer-token audience and scope restrictions before calling the helper. + ## Refresh cached session data Use `refreshSessionCookieCache(event)` after server-side code updates data returned by `auth.api.getSession()`, `getUserSession(event)`, or `getRequestSession(event)`. diff --git a/src/runtime/server/utils/session.ts b/src/runtime/server/utils/session.ts index 6a268127..ba019c0f 100644 --- a/src/runtime/server/utils/session.ts +++ b/src/runtime/server/utils/session.ts @@ -306,46 +306,59 @@ function updateRequestHeaders(event: ServerEvent, sessionCookie: string, cleared export async function getRequestSession(event: ServerEvent): Promise { const context = getRequestSessionContext(event) - if (context.requestSession !== undefined) - return context.requestSession - const inFlight = context[requestSessionLoadKey] if (inFlight) return inFlight + if (context.requestSession !== undefined) + return context.requestSession + const load = loadSession(event) context[requestSessionLoadKey] = load try { const session = await load - context.requestSession = session + if (context[requestSessionLoadKey] === load) + context.requestSession = session return session } finally { - delete context[requestSessionLoadKey] + if (context[requestSessionLoadKey] === load) + delete context[requestSessionLoadKey] } } export async function getUserSession(event: ServerEvent): Promise { const context = getRequestSessionContext(event) - if (context.requestSession !== undefined) - return context.requestSession - const inFlight = context[requestSessionLoadKey] if (inFlight) return inFlight + if (context.requestSession !== undefined) + return context.requestSession + return loadSession(event) } +export function setRequestSession(event: ServerEvent, session: AppSession | null): void { + const context = getRequestSessionContext(event) + context.requestSession = session + delete context[requestSessionLoadKey] +} + export async function refreshSessionCookieCache(event: ServerEvent): Promise { const context = getRequestSessionContext(event) const inFlight = context[requestSessionLoadKey] - if (inFlight) - await inFlight.catch(() => undefined) + const load = (inFlight ?? Promise.resolve(null)).catch(() => undefined).then(async () => { + if (context[requestSessionLoadKey] !== load) + return context.requestSession ?? null + + delete context.requestSession + const { headers, response } = await loadFreshSession(event) + + if (context[requestSessionLoadKey] !== load) + return context.requestSession ?? null - delete context.requestSession - const load = loadFreshSession(event).then(({ headers, response }) => { appendSetCookieHeaders(event, headers) context.requestSession = response return response diff --git a/test/get-request-session.test.ts b/test/get-request-session.test.ts index e7be020c..c987a557 100644 --- a/test/get-request-session.test.ts +++ b/test/get-request-session.test.ts @@ -271,6 +271,156 @@ describe('getUserSession', () => { }) }) +describe('setRequestSession', () => { + beforeEach(() => { + vi.clearAllMocks() + getSessionMock.mockReset() + createSessionMock.mockReset() + }) + + it('makes a supplied session authoritative for downstream helpers without setting cookies', async () => { + const suppliedSession = { + user: { id: 'bearer-user', role: 'member' }, + session: { id: 'bearer-session' }, + } + const { getRequestSession, getUserSession, requireUserSession, setRequestSession } = await import('../src/runtime/server/utils/session') + const event = createEvent() + + setRequestSession(event, suppliedSession as any) + + await expect(getRequestSession(event)).resolves.toBe(suppliedSession) + await expect(getUserSession(event)).resolves.toBe(suppliedSession) + await expect(requireUserSession(event)).resolves.toBe(suppliedSession) + expect(getSessionMock).not.toHaveBeenCalled() + expect(event.context.requestSession).toBe(suppliedSession) + expect(event.node.res.getHeader('set-cookie')).toBeUndefined() + }) + + it('treats null as an authoritative request session value', async () => { + const { getRequestSession, getUserSession, requireUserSession, setRequestSession } = await import('../src/runtime/server/utils/session') + const event = createEvent() + + setRequestSession(event, null) + + await expect(getRequestSession(event)).resolves.toBeNull() + await expect(getUserSession(event)).resolves.toBeNull() + await expect(requireUserSession(event)).rejects.toMatchObject({ + statusCode: 401, + statusMessage: 'Authentication required', + }) + expect(getSessionMock).not.toHaveBeenCalled() + }) + + it('takes ownership from an earlier in-flight session lookup', async () => { + let resolveSession: ((value: unknown) => void) | undefined + getSessionMock.mockImplementation(() => new Promise((resolve) => { + resolveSession = resolve + })) + + const staleSession = { + user: { id: 'cookie-user' }, + session: { id: 'cookie-session' }, + } + const suppliedSession = { + user: { id: 'bearer-user' }, + session: { id: 'bearer-session' }, + } + const { getRequestSession, getUserSession, requireUserSession, setRequestSession } = await import('../src/runtime/server/utils/session') + const event = createEvent() + + const earlierLookup = getRequestSession(event) + setRequestSession(event, suppliedSession as any) + expect(event.context.requestSession).toBe(suppliedSession) + + const laterRequestLookup = getRequestSession(event) + const laterUserLookup = getUserSession(event) + const laterRequiredLookup = requireUserSession(event) + + resolveSession?.(staleSession) + + await expect(earlierLookup).resolves.toEqual(staleSession) + await expect(laterRequestLookup).resolves.toBe(suppliedSession) + await expect(laterUserLookup).resolves.toBe(suppliedSession) + await expect(laterRequiredLookup).resolves.toBe(suppliedSession) + await expect(getRequestSession(event)).resolves.toBe(suppliedSession) + expect(event.context.requestSession).toBe(suppliedSession) + }) + + it('supplies the session when an earlier lookup rejects', async () => { + let rejectSession: ((reason?: unknown) => void) | undefined + getSessionMock.mockImplementation(() => new Promise((_resolve, reject) => { + rejectSession = reject + })) + + const suppliedSession = { + user: { id: 'bearer-user' }, + session: { id: 'bearer-session' }, + } + const { getRequestSession, setRequestSession } = await import('../src/runtime/server/utils/session') + const event = createEvent() + + const earlierLookup = getRequestSession(event) + setRequestSession(event, suppliedSession as any) + const laterLookup = getRequestSession(event) + + rejectSession?.(new Error('session lookup failed')) + + await expect(earlierLookup).rejects.toThrow('session lookup failed') + await expect(laterLookup).resolves.toBe(suppliedSession) + await expect(getRequestSession(event)).resolves.toBe(suppliedSession) + }) + + it('does not restore the supplied session after a newer session cookie is set', async () => { + let resolveSession: ((value: unknown) => void) | undefined + const staleSession = { + user: { id: 'cookie-user' }, + session: { id: 'cookie-session' }, + } + const suppliedSession = { + user: { id: 'bearer-user' }, + session: { id: 'bearer-session' }, + } + const cookieSession = { + user: { id: 'new-cookie-user' }, + session: { id: 'new-cookie-session' }, + } + getSessionMock + .mockImplementationOnce(() => new Promise((resolve) => { + resolveSession = resolve + })) + .mockResolvedValueOnce(cookieSession) + + const { getRequestSession, setRequestSession, setSessionCookie } = await import('../src/runtime/server/utils/session') + const event = createEvent() + + const earlierLookup = getRequestSession(event) + setRequestSession(event, suppliedSession as any) + await setSessionCookie(event, 'session-token') + + resolveSession?.(staleSession) + + await expect(earlierLookup).resolves.toEqual(staleSession) + await expect(getRequestSession(event)).resolves.toBe(cookieSession) + expect(event.context.requestSession).toBe(cookieSession) + expect(getSessionMock).toHaveBeenCalledTimes(2) + }) + + it('supports events without a context object', async () => { + const suppliedSession = { + user: { id: 'bearer-user' }, + session: { id: 'bearer-session' }, + } + const { getRequestSession, setRequestSession } = await import('../src/runtime/server/utils/session') + const event = createEventWithoutContext() + + setRequestSession(event, suppliedSession as any) + + await expect(getRequestSession(event)).resolves.toBe(suppliedSession) + expect(getSessionMock).not.toHaveBeenCalled() + expect('context' in event).toBe(false) + }) +}) + describe('refreshSessionCookieCache', () => { beforeEach(() => { vi.clearAllMocks() @@ -315,6 +465,71 @@ describe('refreshSessionCookieCache', () => { sessionTokenCookie, ]) }) + + it('cannot overwrite a request session supplied after the refresh starts', async () => { + let resolveRefresh: ((value: unknown) => void) | undefined + const freshSession = { + user: { id: 'fresh-cookie-user' }, + session: { id: 'fresh-cookie-session' }, + } + const suppliedSession = { + user: { id: 'bearer-user' }, + session: { id: 'bearer-session' }, + } + const staleCacheCookie = 'better-auth.session_data=stale; Path=/; HttpOnly' + const staleHeaders = new Headers({ 'set-cookie': staleCacheCookie }) + getSessionMock.mockImplementation(() => new Promise((resolve) => { + resolveRefresh = resolve + })) + + const { getRequestSession, refreshSessionCookieCache, setRequestSession } = await import('../src/runtime/server/utils/session') + const event = createEvent() + + const refreshing = refreshSessionCookieCache(event) + await vi.waitFor(() => expect(getSessionMock).toHaveBeenCalledOnce()) + + setRequestSession(event, suppliedSession as any) + resolveRefresh?.({ headers: staleHeaders, response: freshSession }) + + await expect(refreshing).resolves.toBe(suppliedSession) + await expect(getRequestSession(event)).resolves.toBe(suppliedSession) + expect(event.context.requestSession).toBe(suppliedSession) + expect(getSessionMock).toHaveBeenCalledTimes(1) + expect(event.node.res.getHeader('set-cookie')).toBeUndefined() + }) + + it('does not let an older refresh overwrite a newer session cookie', async () => { + let resolveRefresh: ((value: unknown) => void) | undefined + const staleSession = { + user: { id: 'stale-cookie-user' }, + session: { id: 'stale-cookie-session' }, + } + const newCookieSession = { + user: { id: 'new-cookie-user' }, + session: { id: 'new-cookie-session' }, + } + const staleCacheCookie = 'better-auth.session_data=stale; Path=/; HttpOnly' + const staleHeaders = new Headers({ 'set-cookie': staleCacheCookie }) + getSessionMock + .mockImplementationOnce(() => new Promise((resolve) => { + resolveRefresh = resolve + })) + .mockResolvedValueOnce(newCookieSession) + + const { getRequestSession, refreshSessionCookieCache, setSessionCookie } = await import('../src/runtime/server/utils/session') + const event = createEvent() + + const refreshing = refreshSessionCookieCache(event) + await setSessionCookie(event, 'new-session-token') + + resolveRefresh?.({ headers: staleHeaders, response: staleSession }) + + await expect(refreshing).resolves.toBeNull() + await expect(getRequestSession(event)).resolves.toBe(newCookieSession) + expect(event.context.requestSession).toBe(newCookieSession) + expect(getSessionMock).toHaveBeenCalledTimes(2) + expect(event.node.res.getHeader('set-cookie')).not.toContain(staleCacheCookie) + }) }) describe('requireUserSession', () => { diff --git a/test/require-user-session-typing.test.ts b/test/require-user-session-typing.test.ts index af5f9fb2..c46de8f5 100644 --- a/test/require-user-session-typing.test.ts +++ b/test/require-user-session-typing.test.ts @@ -92,7 +92,7 @@ export type UserMatch = { [K in keyof T]?: T[K] | T[K][] } `) writeFileSync(join(testDir, 'check.ts'), `import type { H3Event } from 'h3' -import { requireUserSession } from './runtime/server/utils/session' +import { requireUserSession, setRequestSession } from './runtime/server/utils/session' export async function check(event: H3Event) { const session = await requireUserSession(event, { @@ -100,6 +100,9 @@ export async function check(event: H3Event) { rule: ({ user }) => Boolean(user.address), }) + setRequestSession(event, session) + setRequestSession(event, null) + return session.user.address } `)