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: 5 additions & 0 deletions .changeset/large-pumas-dig.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hono/session': minor
---

Initial release
200 changes: 200 additions & 0 deletions packages/session/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
# Session middleware for Hono

[![codecov](https://codecov.io/github/honojs/middleware/graph/badge.svg?flag=session)](https://codecov.io/github/honojs/middleware)

Session middleware for Hono using encrypted JSON Web Tokens.

This middleware depends on [`jose`](https://github.com/panva/jose) for JSON Web Encryption.

Other resources worth reading include:

- [The Copenhagen Book](https://thecopenhagenbook.com/) by [Pilcrow](https://github.com/pilcrowOnPaper)
- [Session Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html) from [OWASP](https://cheatsheetseries.owasp.org/index.html)

## Installation

```sh
npm i @hono/session
```

## Environment Variables

```sh
AUTH_SECRET=
```

> [!TIP]
> Quickly generate a good secret with `openssl`
>
> ```sh
> $ openssl rand -base64 32
> ```

## Options

| Option | Type | Description |
| --------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `generateId`? | `() => string` | Function to generate a unique session ID |
| `secret`? | `string` \| [`EncryptionKey`](#EncryptionKey) | 32-byte, hex-encoded string, or encryption key, used to encrypt the session cookie. Defaults to `process.env.AUTH_SECRET` |
| `duration`? | [`MaxAgeDuration`](#MaxAgeDuration) | The maximum age duration of the session cookie. By default, no maximum age is set |
| `deleteCookie`? | [`DeleteCookie`](https://hono.dev/docs/helpers/cookie#deletecookie) | Defaults to `hono/cookie#deleteCookie` |
| `getCookie`? | [`GetCookie`](https://hono.dev/docs/helpers/cookie) | Defaults to `hono/cookie#getCookie` |
| `setCookie`? | [`SetCookie`](https://hono.dev/docs/helpers/cookie) | Defaults to `hono/cookie#setCookie` |

## `EncryptionKey`

- [`jose.CryptoKey`](https://github.com/panva/jose/blob/main/docs/types/type-aliases/CryptoKey.md) | [`jose.KeyObject`](https://github.com/panva/jose/blob/main/docs/types/interfaces/KeyObject.md) | [`jose.JWK`](https://github.com/panva/jose/blob/main/docs/types/interfaces/JWK.md) | [`Uint8Array`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array)

## `MaxAgeDuration`

See [Session lifetime](https://thecopenhagenbook.com/sessions#session-lifetime)

> [!IMPORTANT]
> By default, session cookies do not expire.
> It is recommended to provide value for `duration.absolute`

### Properties

| Property | Type | Description |
| ------------- | -------- | ---------------------------------------------------------------------------------------------------------------- |
| `absolute` | `number` | Duration in seconds a session will be valid for, after which it will be expired and have to be re-authenticated. |
| `inactivity`? | `number` | Duration in seconds a session will be considered active, during which the session max age can be extended. |

## `Session<Data>`

### Properties

| Property | Type | Description |
| --------------- | ---------------- | --------------------- |
| readonly `data` | `Data` \| `null` | Current session data. |

### Methods

#### delete()

delete(): `void`

Delete the current session, removing the session cookie and data from storage.

#### Returns

`void`

### get()

get(`refresh`): `Promise`<`Data` | `null`>

Get the current session data, optionally calling the provided refresh function.

#### Parameters

| Parameter | Type | Description |
| ---------- | ------------------------------- | -------------------------- |
| `refresh`? | [`Refresh<Data>`](#refreshdata) | Optional refresh function. |

### Returns

`Promise`<`Data` | `null`>

## `Refresh<Data>`

refresh(`expired`) => `Promise`<`Data` | `null`>

Function to refresh the session data. If the refresh function returns null, the session will be destroyed.

#### Parameters

| Parameter | Type | Description |
| --------- | ---------------- | ------------------- |
| `expired` | `Data` \| `null` | Expire session data |

#### Returns

`Data` | `null`

### update()

update(`data`): `Promise`<`void`>

Update the current session with the provided session data.

#### Parameters

| Parameter | Type | Description |
| --------- | --------------------------------------- | ----------------------------------- |
| `data` | `Data` \| [`Update<Data>`](#updatedata) | New data or function to update data |

#### Returns

`Promise`<`void`>

## `Update<Data>`

update(`prevData`) => `Data`

Function to update previous session data.

#### Parameters

| Parameter | Type | Description |
| ---------- | ---------------- | --------------------- |
| `prevData` | `Data` \| `null` | Previous session data |

#### Returns

`Data`

## Example

```ts
import { useSession } from '@hono/session'
import { Hono } from 'hono'

const app = new Hono()

app.use(useSession()).get('/', async (c) => {
const data = await c.var.session.get()
return c.json(data)
})

export default app
```

### With Session storage

```ts
import { useSession, useSessionStorage } from '@hono/session'
import type { SessionEnv } from '@hono/session'
import { Hono } from 'hono'

const app = new Hono<SessionEnv>()

app.use(
useSessionStorage({
delete(sid) {},
async get(sid) {},
set(sid, value) {},
}),
useSession()
)

app.get('/', async (c) => {
const data = await c.var.session.get()
return c.json(data)
})

export default app
```

See also:

- [Cloudflare KV as session storage](./examples/cloudflare-kv.ts)
- [Using Unstorage as session storage](./examples/unstorage.ts)

## Author

Jonathan haines <https://github.com/barrythepenguin>

## License

MIT
69 changes: 69 additions & 0 deletions packages/session/examples/cloudflare-kv.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { createExecutionContext, env, waitOnExecutionContext } from 'cloudflare:test'
import { createTestSession } from '../src/helper/testing'
import { app, secret } from './cloudflare-kv'

const { soon, recent, encrypt, sid, sub } = createTestSession({ secret })

describe('Cloudflare KV adapter', () => {
it('gets session data', async () => {
const ctx = createExecutionContext()
await env.SESSION_KV.put(sid, JSON.stringify({ sub }))
const cookie = await encrypt({ iat: recent, exp: soon, sid })

const res = await app.request(
'/session',
{
headers: { cookie: `sid=${cookie}` },
},
env,
ctx
)
await waitOnExecutionContext(ctx)
const data = await res.json()

expect(res.status).toBe(200)
expect(data).toStrictEqual({ sub })
})

it('updates session data', async () => {
const ctx = createExecutionContext()
await env.SESSION_KV.put(sid, JSON.stringify({ sub }))
const newSub = 'new-subject'
const cookie = await encrypt({ iat: recent, exp: soon, sid })
const res = await app.request(
'/session',
{
body: JSON.stringify({ sub: newSub }),
headers: { cookie: `sid=${cookie}` },
method: 'PUT',
},
env,
ctx
)
await waitOnExecutionContext(ctx)
const data = await res.json()

expect(res.status).toBe(200)
expect(data).toStrictEqual({ sub: newSub })
await expect(env.SESSION_KV.get(sid, 'json')).resolves.toStrictEqual({ sub: newSub })
})

it('deletes session data', async () => {
const ctx = createExecutionContext()
await env.SESSION_KV.put(sid, JSON.stringify({ sub }))
const cookie = await encrypt({ iat: recent, exp: soon, sid })
const res = await app.request(
'/session',
{
headers: { cookie: `sid=${cookie}` },
method: 'DELETE',
},
env,
ctx
)
await waitOnExecutionContext(ctx)

expect(res.status).toBe(204)
await expect(env.SESSION_KV.get(sid, 'json')).resolves.toBeNull()
})
})
51 changes: 51 additions & 0 deletions packages/session/examples/cloudflare-kv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { env } from 'cloudflare:test'
import { Hono } from 'hono'
import type { SessionEnv } from '../src'
import { useSession, useSessionStorage } from '../src'
import * as cookies from '../src/cookies'

export const secret = cookies.generateId(16)

/**
* Example hono app using Cloudflare KV as session storage.
*
* This example assumes you have a Cloudflare KV namespace named `SESSION_KV`.
*/
export const app = new Hono<SessionEnv>()

app.use(
useSessionStorage((c) => ({
delete(sid) {
c.executionCtx.waitUntil(env.SESSION_KV.delete(sid))
},
get(sid) {
return env.SESSION_KV.get(sid, 'json')
},
set(sid, data) {
c.executionCtx.waitUntil(
env.SESSION_KV.put(sid, JSON.stringify(data), {
// Optionally configure session data to expire some time after the session cookie expires.
expirationTtl: 2_592_000, // 30 days in seconds
})
)
},
})),
useSession({ secret })
)

app.get('/session', async (c) => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I updated the examples to the "not chained" style. It's common for users. If you pass SessionEnv to Hono as a generic, you can write it like this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense, thank you!

const data = await c.var.session.get()
return c.json(data)
})

app.put('/session', async (c) => {
const data = await c.req.json()
await c.var.session.update(data)
return c.json(c.var.session.data)
})

app.delete('/session', async (c) => {
await c.var.session.get()
c.var.session.delete()
return c.body(null, 204)
})
5 changes: 5 additions & 0 deletions packages/session/examples/cloudflare-types.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
declare module 'cloudflare:test' {
interface ProvidedEnv {
SESSION_KV: KVNamespace
}
}
46 changes: 46 additions & 0 deletions packages/session/examples/unstorage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { createTestSession } from '../src/helper/testing'
import { app, secret, storage } from './unstorage'

const { soon, recent, encrypt, sid, sub } = createTestSession({ secret })

describe('Unstorage adapter', () => {
it('gets session data', async () => {
await storage.set(sid, { sub })
const cookie = await encrypt({ iat: recent, exp: soon, sid })
const res = await app.request('/session', {
headers: { cookie: `sid=${cookie}` },
})
const data = await res.json()

expect(res.status).toBe(200)
expect(data).toStrictEqual({ sub })
})

it('updates session data', async () => {
await storage.set(sid, { sub })
const newSub = 'new-subject'
const cookie = await encrypt({ iat: recent, exp: soon, sid })
const res = await app.request('/session', {
body: JSON.stringify({ sub: newSub }),
headers: { cookie: `sid=${cookie}` },
method: 'PUT',
})
const data = await res.json()

expect(res.status).toBe(200)
expect(data).toStrictEqual({ sub: newSub })
await expect(storage.get(sid)).resolves.toStrictEqual({ sub: newSub })
})

it('deletes session data', async () => {
await storage.set(sid, { sub })
const cookie = await encrypt({ iat: recent, exp: soon, sid })
const res = await app.request('/session', {
headers: { cookie: `sid=${cookie}` },
method: 'DELETE',
})

expect(res.status).toBe(204)
await expect(storage.get(sid)).resolves.toBeNull()
})
})
Loading