diff --git a/README.md b/README.md index dfd68d9..7f2b79e 100644 --- a/README.md +++ b/README.md @@ -514,6 +514,22 @@ export default defineNitroPlugin(() => { }) ``` +### Extending Cookie Lifetime + +When using cookie mode, only the session data itself can be enriched through the session hooks. If you want to extend the lifetime of the cookie itself, you could use a middleware that refreshes the cookie by re-setting the session data. (At this point you can also add any other custom data to your cookies) + +Example middleware: + +```ts +// server/middleware/cookie-lifetime-extend.ts +export default defineEventHandler(async (event) => { + const session = await getUserSession(event) + if (session && Object.keys(session).length > 0) { + await setUserSession(event, session) + } +}) +``` + ## Server-Side Rendering You can make authenticated requests both from the client and the server. However, you must use `useRequestFetch()` to make authenticated requests during SSR if you are not using `useFetch()` @@ -579,42 +595,158 @@ If you are caching your routes with `routeRules`, please make sure to use [Nitro ## Configuration -We leverage `runtimeConfig.session` to give the defaults option to [h3 `useSession`](https://h3.unjs.io/examples/handle-session). +### Session Storage -You can overwrite the options in your `nuxt.config.ts`: +Nuxt Auth Utils supports different session storage modes that can be configured in your `nuxt.config.ts`: ```ts export default defineNuxtConfig({ modules: ['nuxt-auth-utils'], + auth: { + storageType: 'cookie', // 'memory', 'cache', 'nuxt-session' + } +}) +``` + +#### Storage Types + +- **`cookie`** (default): Stores session data in encrypted cookies. This is the most secure option and works well for most use cases. + + ```ts + auth: { + storageType: 'cookie' + } + ``` + +- **`cache`**: Uses Nitro's cache storage. Useful when you need to store larger session data that might exceed cookie size limits. + + ```ts + auth: { + storageType: 'cache' + } + ``` + +- **`memory`**: Stores sessions in memory. Only suitable for development or testing. + + ```ts + auth: { + storageType: 'memory' + } + ``` + +> [!WARNING] +> Memory storage is cleared when the server restarts and doesn't work with multiple server instances. Not recommended for production use. + +- **`nuxt-session`**: Uses a custom storage mount named 'nuxt-session'. Useful when you want to use a different storage driver. + + ```ts + // nuxt.config.ts + export default defineNuxtConfig({ + auth: { + storageType: 'nuxt-session' + }, + nitro: { + storage: { + 'nuxt-session': { + driver: 'fsLite', + base: './.data/sessions' + } + } + } + }) + ``` + +> [!NOTE] +> This will store sessions in the `.data/sessions` directory. Make sure to add `.data` to your `.gitignore`. + +#### Session Configuration + +You can configure session behavior through the `auth` or `runtimeConfig` options: + +```ts +export default defineNuxtConfig({ + auth: { + storageType: 'cookie' + }, runtimeConfig: { session: { - maxAge: 60 * 60 * 24 * 7 // 1 week + name: 'nuxt-session', // Cookie name + maxAge: 60 * 60 * 24 * 7, // 1 week + password: process.env.NUXT_SESSION_PASSWORD, + cookie: { + sameSite: 'lax', + // Additional cookie options + // secure: true, + // domain: 'example.com', + // path: '/' + } } } }) ``` -Our defaults are: +We leverage `runtimeConfig.session` to give the defaults option to [h3 `useSession`](https://h3.unjs.io/examples/handle-session). +Checkout the [`SessionConfig`](https://github.com/unjs/h3/blob/c04c458810e34eb15c1647e1369e7d7ef19f567d/src/utils/session.ts#L20) for all options. + +> [!NOTE] +> When using non-cookie storage types, the cookie only contains a session ID while the actual session data is stored in the selected storage. + +When using a non-cookie mode ```ts -{ - name: 'nuxt-session', - password: process.env.NUXT_SESSION_PASSWORD || '', - cookie: { - sameSite: 'lax' +export default defineNuxtConfig({ + auth: { + storageType: 'cache', + sessionInactivityMaxAge: 60 * 60 * 24 * 30, // Session timeout after inactivity (30 days) + autoExtendSession: true // Extend session on each request + }, + runtimeConfig: { + session: { + password: process.env.NUXT_SESSION_PASSWORD, + } } -} +}) +``` + +> [!IMPORTANT] +> The `sessionInactivityMaxAge` option is specifically designed for non-cookie storage types to manage and cleanup inactive sessions. When using this configuration, cookies still respect the `maxAge` setting from the session configuration, if one is specified. Whether you need both `maxAge` and `sessionInactivityMaxAge` will depend on your specific application requirements and session management strategy. + +## Session Cleanup + +When using non-cookie storage types, you may want to clean up expired sessions periodically. This can be done using Nitro's scheduled tasks feature. + +1. Create a task file: + +```ts:server/tasks/clear-sessions.ts +export default defineTask({ + meta: { + name: 'clear-sessions', + description: 'Clear expired sessions', + }, + run({ payload, context }) { + console.log('Running clear-sessions task...') + cleanupOrphanedUserSessions() + return { result: 'Success' } + }, +}) ``` -You can also overwrite the session config by passing it as 3rd argument of the `setUserSession` and `replaceUserSession` functions: +2. Configure the task schedule in your `nuxt.config.ts`: ```ts -await setUserSession(event, { ... } , { - maxAge: 60 * 60 * 24 * 7 // 1 week +export default defineNuxtConfig({ + nitro: { + experimental: { + tasks: true + }, + scheduledTasks: { + '*/5 * * * *': ['clear-sessions'] // Run every 5 minutes + } + } }) ``` -Checkout the [`SessionConfig`](https://github.com/unjs/h3/blob/c04c458810e34eb15c1647e1369e7d7ef19f567d/src/utils/session.ts#L20) for all options. +This will automatically clean up any expired sessions based on your `sessionInactivityMaxAge` configuration. ## More diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index 5bbe13f..566d2bb 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -20,9 +20,21 @@ export default defineNuxtConfig({ nitro: { experimental: { database: true, + // tasks: true, }, + // scheduledTasks: { + // '*/1 * * * *': ['clear-sessions'], // every minute clear overdue sessions + // }, }, auth: { webAuthn: true, + // storageType: 'cache', + // sessionInactivityMaxAge: 60 * 2, // 2 minutes + // autoExtendSession: true, }, + // runtimeConfig: { + // session: { + // maxAge: 60 * 60 * 24 * 7, // 7 days + // }, + // }, }) diff --git a/playground/server/tasks/clear-sessions.ts b/playground/server/tasks/clear-sessions.ts new file mode 100644 index 0000000..f6bfb7d --- /dev/null +++ b/playground/server/tasks/clear-sessions.ts @@ -0,0 +1,11 @@ +export default defineTask({ + meta: { + name: 'clear-sessions', + description: 'Clear expired sessions', + }, + run({ payload, context }) { + console.log('Running clear-sessions task...') + cleanupOrphanedUserSessions() + return { result: 'Success' } + }, +}) diff --git a/src/module.ts b/src/module.ts index b5103e6..d709a32 100644 --- a/src/module.ts +++ b/src/module.ts @@ -22,6 +22,25 @@ export interface ModuleOptions { * @default false */ webAuthn?: boolean + /** + * Use session storage + * Use 'cache' for standard cache storage, + * 'cookie' for cookies, + * 'memory' for in-memory storage, + * 'nuxt-session' for a custom storage mount named 'nuxt-session' + * @default 'cache' + */ + storageType?: 'cache' | 'cookie' | 'memory' | 'nuxt-session' + /** + * Session inactivity max age in milliseconds + * @default 2592000000 (30 days) + */ + sessionInactivityMaxAge?: number + /** + * Auto extend session + * @default true + */ + autoExtendSession?: boolean /** * Hash options used for password hashing */ @@ -49,6 +68,9 @@ export default defineNuxtModule({ // Default configuration options of the Nuxt module defaults: { webAuthn: false, + storageType: 'cookie', + sessionInactivityMaxAge: 2592000, // 30 days + autoExtendSession: true, hash: { scrypt: {}, }, @@ -143,6 +165,17 @@ export default defineNuxtModule({ authenticate: {}, }) + runtimeConfig.useSessionStorageType = runtimeConfig.useSessionStorageType || options.storageType + runtimeConfig.sessionInactivityMaxAge = runtimeConfig.sessionInactivityMaxAge || options.sessionInactivityMaxAge + runtimeConfig.autoExtendSession = runtimeConfig.autoExtendSession || options.autoExtendSession + logger.withTag('nuxt-auth-utils').info(`Using session storage type: ${runtimeConfig.useSessionStorageType}`) + if (runtimeConfig.useSessionStorageType === 'memory') { + logger.warn('Using in-memory session storage, this is not recommended for production') + if (!nuxt.options.dev) { + logger.error('You are not running in dev mode, please make sure this is intentional') + } + } + // OAuth settings runtimeConfig.oauth = defu(runtimeConfig.oauth, {}) // GitHub OAuth diff --git a/src/runtime/server/utils/session.ts b/src/runtime/server/utils/session.ts index c9f7384..dd7d7c3 100644 --- a/src/runtime/server/utils/session.ts +++ b/src/runtime/server/utils/session.ts @@ -2,7 +2,7 @@ import type { H3Event, SessionConfig } from 'h3' import { useSession, createError } from 'h3' import { defu } from 'defu' import { createHooks } from 'hookable' -import { useRuntimeConfig } from '#imports' +import { useRuntimeConfig, useStorage } from '#imports' import type { UserSession, UserSessionRequired } from '#auth-utils' export interface SessionHooks { @@ -23,10 +23,28 @@ export const sessionHooks = createHooks() /** * Get the user session from the current request * @param event The Request (h3) event + * @param extendSession Optional. If true, the session will be extended by updating the last access timestamp. + * If not provided, falls back to autoExtendSession runtime config value. * @returns The user session */ -export async function getUserSession(event: H3Event) { - return (await _useSession(event)).data +export async function getUserSession(event: H3Event, extendSession?: boolean) { + const runtimeConfig = useRuntimeConfig(event) + const session = await _useSession(event) + + const sessionStorage = getSessionStorage() + if (sessionStorage) { + const data = await sessionStorage.getItem(`nuxt-session:${session.id}`) + if (data) { + if (extendSession ?? runtimeConfig.autoExtendSession) { + data.lastAccess = Date.now() + await sessionStorage.setItem(`nuxt-session:${session.id}`, data) + } + return data + } + return {} as UserSession + } + + return session.data } /** * Set a user session @@ -37,7 +55,18 @@ export async function getUserSession(event: H3Event) { export async function setUserSession(event: H3Event, data: UserSession, config?: Partial) { const session = await _useSession(event, config) - await session.update(defu(data, session.data)) + const sessionStorage = getSessionStorage() + if (sessionStorage) { + const existingSessionData = await sessionStorage.getItem(`nuxt-session:${session.id}`) + const dataToApply = defu(data, existingSessionData) + await sessionStorage.setItem(`nuxt-session:${session.id}`, { + ...dataToApply, + lastAccess: Date.now(), + }) + } + else { + await session.update(defu(data, session.data)) + } return session.data } @@ -50,8 +79,17 @@ export async function setUserSession(event: H3Event, data: UserSession, config?: export async function replaceUserSession(event: H3Event, data: UserSession, config?: Partial) { const session = await _useSession(event, config) - await session.clear() - await session.update(data) + const sessionStorage = getSessionStorage() + if (sessionStorage) { + await sessionStorage.setItem(`nuxt-session:${session.id}`, { + ...data, + lastAccess: Date.now(), + }) + } + else { + await session.clear() + await session.update(data) + } return session.data } @@ -65,7 +103,14 @@ export async function clearUserSession(event: H3Event, config?: Partial(currentSessionKey) + const currentSessionAge = session?.lastAccess ? Date.now() - session.lastAccess : 0 + if (currentSessionAge > maxAge) { + await sessionStorage.removeItem(currentSessionKey) + } + } +} + let sessionConfig: SessionConfig function _useSession(event: H3Event, config: Partial = {}) { @@ -104,3 +180,16 @@ function _useSession(event: H3Event, config: Partial = {}) { const finalConfig = defu(config, sessionConfig) as SessionConfig return useSession(event, finalConfig) } + +function getSessionStorage() { + const runtimeConfig = useRuntimeConfig() + switch (runtimeConfig.useSessionStorageType) { + case 'memory': + return useStorage() + case 'cache': + return useStorage('cache') + case 'nuxt-session': + return useStorage('nuxt-session') + } + return undefined +} diff --git a/src/runtime/types/session.ts b/src/runtime/types/session.ts index 2533f67..3e2f91b 100644 --- a/src/runtime/types/session.ts +++ b/src/runtime/types/session.ts @@ -15,6 +15,10 @@ export interface UserSession { * Private session data, only available on server/ code */ secure?: SecureSessionData + /** + * Timestamp of last access + */ + lastAccess?: number /** * Extra session data, available on client and server */