Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add server side session storage #265

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
Open
Changes from 3 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
160 changes: 146 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
@@ -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((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

12 changes: 12 additions & 0 deletions playground/nuxt.config.ts
Original file line number Diff line number Diff line change
@@ -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
// },
// },
})
11 changes: 11 additions & 0 deletions playground/server/tasks/clear-sessions.ts
Original file line number Diff line number Diff line change
@@ -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' }
},
})
33 changes: 33 additions & 0 deletions src/module.ts
Original file line number Diff line number Diff line change
@@ -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<ModuleOptions>({
// 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<ModuleOptions>({
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
103 changes: 96 additions & 7 deletions src/runtime/server/utils/session.ts
Original file line number Diff line number Diff line change
@@ -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<SessionHooks>()
/**
* 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<UserSession>(`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<SessionConfig>) {
const session = await _useSession(event, config)

await session.update(defu(data, session.data))
const sessionStorage = getSessionStorage()
if (sessionStorage) {
const existingSessionData = await sessionStorage.getItem<UserSession>(`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<SessionConfig>) {
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<SessionC
const session = await _useSession(event, config)

await sessionHooks.callHookParallel('clear', session.data, event)
await session.clear()

const sessionStorage = getSessionStorage()
if (sessionStorage) {
await sessionStorage.removeItem(`nuxt-session:${session.id}`)
}
else {
await session.clear()
}

return true
}
@@ -91,6 +136,37 @@ export async function requireUserSession(event: H3Event, opts: { statusCode?: nu
return userSession as UserSessionRequired
}

/**
* Cleanup orphaned sessions
* This should be called either
* on a request basis with a middleware for example
* or by a scheduled task
* @see https://github.com/atinux/nuxt-auth-utils
*/
export async function cleanupOrphanedUserSessions() {
const runtimeConfig = useRuntimeConfig()
const maxAge = runtimeConfig.sessionInactivityMaxAge * 1000
if (!maxAge) {
console.warn('No session inactivity max age configured, skipping cleanup')
return
}

const sessionStorage = getSessionStorage()
if (!sessionStorage) {
console.warn('No session storage configured, skipping cleanup')
return
}

const sessionKeys = await sessionStorage.getKeys('nuxt-session')
for (const currentSessionKey of sessionKeys) {
const session = await sessionStorage.getItem<UserSession>(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<SessionConfig> = {}) {
@@ -104,3 +180,16 @@ function _useSession(event: H3Event, config: Partial<SessionConfig> = {}) {
const finalConfig = defu(config, sessionConfig) as SessionConfig
return useSession<UserSession>(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
}
4 changes: 4 additions & 0 deletions src/runtime/types/session.ts
Original file line number Diff line number Diff line change
@@ -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
*/