Skip to content

Commit 4f11809

Browse files
feature(session): add session middleware
1 parent aee369e commit 4f11809

19 files changed

+2874
-3
lines changed

.changeset/large-pumas-dig.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@hono/session': minor
3+
---
4+
5+
Initial release

packages/session/README.md

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
# Session middleware for Hono
2+
3+
[![codecov](https://codecov.io/github/honojs/middleware/graph/badge.svg?flag=session)](https://codecov.io/github/honojs/middleware)
4+
5+
Session middleware for Hono using encrypted JSON Web Tokens.
6+
7+
This middleware depends on the following pacakges:
8+
9+
- [`@panva/hkdf`](https://github.com/panva/hkdf)
10+
- [`jose`](https://github.com/panva/jose)
11+
12+
Other resources worth reading include:
13+
14+
- [The Copenhagen Book](https://thecopenhagenbook.com/) by [Pilcrow](https://github.com/pilcrowOnPaper)
15+
- [Session Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html) from [OWASP](https://cheatsheetseries.owasp.org/index.html)
16+
17+
## Installation
18+
19+
```sh
20+
npm i @hono/session
21+
```
22+
23+
## Environment Variables
24+
25+
```sh
26+
AUTH_SECRET=
27+
```
28+
29+
> [!TIP]
30+
> Quickly generate a good secret with `openssl`
31+
>
32+
> ```sh
33+
> $ openssl rand -base64 32
34+
> ```
35+
36+
## Options
37+
38+
| Option | Type | Description |
39+
| --------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
40+
| `generateId`? | `() => string` | Function to generate a unique session ID |
41+
| `secret`? | `string` \| [`EncryptionKey`](#EncryptionKey) | 32-byte, hex-encoded string, or encryption key, used to encrypt the session cookie. Defaults to `process.env.AUTH_SECRET` |
42+
| `duration`? | [`MaxAgeDuration`](#MaxAgeDuration) | The maximum age duration of the session cookie. By default, no maximum age is set |
43+
| `deleteCookie`? | [`DeleteCookie`](https://hono.dev/docs/helpers/cookie#deletecookie) | Defaults to `hono/cookie#deleteCookie` |
44+
| `getCookie`? | [`GetCookie`](https://hono.dev/docs/helpers/cookie) | Defaults to `hono/cookie#getCookie` |
45+
| `setCookie`? | [`SetCookie`](https://hono.dev/docs/helpers/cookie) | Defaults to `hono/cookie#setCookie` |
46+
47+
## `EncryptionKey`
48+
49+
- [`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)
50+
51+
## `MaxAgeDuration`
52+
53+
See [Session lifetime](https://thecopenhagenbook.com/sessions#session-lifetime)
54+
55+
> [!IMPORTANT]
56+
> By default, session cookies do not expire.
57+
> It is recommended to provide value for `duration.absolute`
58+
59+
### Properties
60+
61+
| Property | Type | Description |
62+
| ------------- | -------- | ---------------------------------------------------------------------------------------------------------------- |
63+
| `absolute` | `number` | Duration in seconds a session will be valid for, after which it will be expired and have to be re-authenticated. |
64+
| `inactivity`? | `number` | Duration in seconds a session will be considered active, during which the session max age can be extended. |
65+
66+
## `Session<Data>`
67+
68+
### Properties
69+
70+
| Property | Type | Description |
71+
| --------------- | ---------------- | --------------------- |
72+
| readonly `data` | `Data` \| `null` | Current session data. |
73+
74+
### Methods
75+
76+
#### delete()
77+
78+
delete(): `void`
79+
80+
Delete the current session, removing the session cookie and data from storage.
81+
82+
#### Returns
83+
84+
`void`
85+
86+
### get()
87+
88+
get(`refresh`): `Promise`<`Data` | `null`>
89+
90+
Get the current session data, optionally calling the provided refresh function.
91+
92+
#### Parameters
93+
94+
| Parameter | Type | Description |
95+
| ---------- | ------------------------------- | -------------------------- |
96+
| `refresh`? | [`Refresh<Data>`](#refreshdata) | Optional refresh function. |
97+
98+
### Returns
99+
100+
`Promise`<`Data` | `null`>
101+
102+
## `Refresh<Data>`
103+
104+
refresh(`expired`) => `Promise`<`Data` | `null`>
105+
106+
Function to refresh the session data. If the refresh function returns null, the session will be destroyed.
107+
108+
#### Parameters
109+
110+
| Parameter | Type | Description |
111+
| --------- | ---------------- | ------------------- |
112+
| `expired` | `Data` \| `null` | Expire session data |
113+
114+
#### Returns
115+
116+
`Data` | `null`
117+
118+
### update()
119+
120+
update(`data`): `Promise`<`void`>
121+
122+
Update the current session with the provided session data.
123+
124+
#### Parameters
125+
126+
| Parameter | Type | Description |
127+
| --------- | --------------------------------------- | ----------------------------------- |
128+
| `data` | `Data` \| [`Update<Data>`](#updatedata) | New data or function to update data |
129+
130+
#### Returns
131+
132+
`Promise`<`void`>
133+
134+
## `Update<Data>`
135+
136+
update(`prevData`) => `Data`
137+
138+
Function to update previous session data.
139+
140+
#### Parameters
141+
142+
| Parameter | Type | Description |
143+
| ---------- | ---------------- | --------------------- |
144+
| `prevData` | `Data` \| `null` | Previous session data |
145+
146+
#### Returns
147+
148+
`Data`
149+
150+
## Example
151+
152+
```ts
153+
import { useSession } from '@hono/session'
154+
import { Hono } from 'hono'
155+
156+
const app = new Hono()
157+
158+
app.use(useSession()).get('/', async (c) => {
159+
const data = await c.var.session.get()
160+
return c.json(data)
161+
})
162+
163+
export default app
164+
```
165+
166+
### With Session storage
167+
168+
```ts
169+
import { useSession, useSessionStorage } from '@hono/session'
170+
import { Hono } from 'hono'
171+
172+
const app = new Hono()
173+
174+
app
175+
.use(
176+
useSessionStorage({
177+
delete(sid) {},
178+
async get(sid) {},
179+
set(sid, value) {},
180+
}),
181+
useSession()
182+
)
183+
.get('/', async (c) => {
184+
const data = await c.var.session.get()
185+
return c.json(data)
186+
})
187+
188+
export default app
189+
```
190+
191+
See also:
192+
193+
- [Cloudflare KV as session storage](./examples/cloudflare-kv.ts)
194+
- [Using Unstorage as session storage](./examples/unstorage.ts)
195+
196+
## Author
197+
198+
Jonathan haines <https://github.com/barrythepenguin>
199+
200+
## License
201+
202+
MIT
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { createExecutionContext, env, waitOnExecutionContext } from 'cloudflare:test'
2+
import { createTestSession } from '../src/helper/testing'
3+
import { app, secret } from './cloudflare-kv'
4+
5+
const { soon, recent, encrypt, sid, sub } = createTestSession({ secret })
6+
7+
describe('Cloudflare KV adapter', () => {
8+
it('gets session data', async () => {
9+
const ctx = createExecutionContext()
10+
await env.SESSION_KV.put(sid, JSON.stringify({ sub }))
11+
const cookie = await encrypt({ iat: recent, exp: soon, sid })
12+
13+
const res = await app.request(
14+
'/session',
15+
{
16+
headers: { cookie: `sid=${cookie}` },
17+
},
18+
env,
19+
ctx
20+
)
21+
await waitOnExecutionContext(ctx)
22+
const data = await res.json()
23+
24+
expect(res.status).toBe(200)
25+
expect(data).toStrictEqual({ sub })
26+
})
27+
28+
it('updates session data', async () => {
29+
const ctx = createExecutionContext()
30+
await env.SESSION_KV.put(sid, JSON.stringify({ sub }))
31+
const newSub = 'new-subject'
32+
const cookie = await encrypt({ iat: recent, exp: soon, sid })
33+
const res = await app.request(
34+
'/session',
35+
{
36+
body: JSON.stringify({ sub: newSub }),
37+
headers: { cookie: `sid=${cookie}` },
38+
method: 'PUT',
39+
},
40+
env,
41+
ctx
42+
)
43+
await waitOnExecutionContext(ctx)
44+
const data = await res.json()
45+
46+
expect(res.status).toBe(200)
47+
expect(data).toStrictEqual({ sub: newSub })
48+
await expect(env.SESSION_KV.get(sid, 'json')).resolves.toStrictEqual({ sub: newSub })
49+
})
50+
51+
it('deletes session data', async () => {
52+
const ctx = createExecutionContext()
53+
await env.SESSION_KV.put(sid, JSON.stringify({ sub }))
54+
const cookie = await encrypt({ iat: recent, exp: soon, sid })
55+
const res = await app.request(
56+
'/session',
57+
{
58+
headers: { cookie: `sid=${cookie}` },
59+
method: 'DELETE',
60+
},
61+
env,
62+
ctx
63+
)
64+
await waitOnExecutionContext(ctx)
65+
66+
expect(res.status).toBe(204)
67+
await expect(env.SESSION_KV.get(sid, 'json')).resolves.toBeNull()
68+
})
69+
})
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { env } from 'cloudflare:test'
2+
import { Hono } from 'hono'
3+
import { useSession, useSessionStorage } from '../src'
4+
import * as cookies from '../src/cookies'
5+
6+
export const secret = cookies.generateId(16)
7+
8+
/**
9+
* Example hono app using Cloudflare KV as session storage.
10+
*
11+
* This example assumes you have a Cloudflare KV namespace named `SESSION_KV`.
12+
*/
13+
export const app = new Hono()
14+
.use(
15+
useSessionStorage((c) => ({
16+
delete(sid) {
17+
c.executionCtx.waitUntil(env.SESSION_KV.delete(sid))
18+
},
19+
get(sid) {
20+
return env.SESSION_KV.get(sid, 'json')
21+
},
22+
set(sid, data) {
23+
c.executionCtx.waitUntil(
24+
env.SESSION_KV.put(sid, JSON.stringify(data), {
25+
// Optionally configure session data to expire some time after the session cookie expires.
26+
expirationTtl: 2_592_000, // 30 days in seconds
27+
})
28+
)
29+
},
30+
})),
31+
useSession({ secret })
32+
)
33+
.get('/session', async (c) => {
34+
const data = await c.var.session.get()
35+
return c.json(data)
36+
})
37+
.put('/session', async (c) => {
38+
const data = await c.req.json()
39+
await c.var.session.update(data)
40+
return c.json(c.var.session.data)
41+
})
42+
.delete('/session', async (c) => {
43+
await c.var.session.get()
44+
c.var.session.delete()
45+
return c.body(null, 204)
46+
})
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
declare module 'cloudflare:test' {
2+
interface ProvidedEnv {
3+
SESSION_KV: KVNamespace
4+
}
5+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { createTestSession } from '../src/helper/testing'
2+
import { app, secret, storage } from './unstorage'
3+
4+
const { soon, recent, encrypt, sid, sub } = createTestSession({ secret })
5+
6+
describe('Unstorage adapter', () => {
7+
it('gets session data', async () => {
8+
await storage.set(sid, { sub })
9+
const cookie = await encrypt({ iat: recent, exp: soon, sid })
10+
const res = await app.request('/session', {
11+
headers: { cookie: `sid=${cookie}` },
12+
})
13+
const data = await res.json()
14+
15+
expect(res.status).toBe(200)
16+
expect(data).toStrictEqual({ sub })
17+
})
18+
19+
it('updates session data', async () => {
20+
await storage.set(sid, { sub })
21+
const newSub = 'new-subject'
22+
const cookie = await encrypt({ iat: recent, exp: soon, sid })
23+
const res = await app.request('/session', {
24+
body: JSON.stringify({ sub: newSub }),
25+
headers: { cookie: `sid=${cookie}` },
26+
method: 'PUT',
27+
})
28+
const data = await res.json()
29+
30+
expect(res.status).toBe(200)
31+
expect(data).toStrictEqual({ sub: newSub })
32+
await expect(storage.get(sid)).resolves.toStrictEqual({ sub: newSub })
33+
})
34+
35+
it('deletes session data', async () => {
36+
await storage.set(sid, { sub })
37+
const cookie = await encrypt({ iat: recent, exp: soon, sid })
38+
const res = await app.request('/session', {
39+
headers: { cookie: `sid=${cookie}` },
40+
method: 'DELETE',
41+
})
42+
43+
expect(res.status).toBe(204)
44+
await expect(storage.get(sid)).resolves.toBeNull()
45+
})
46+
})

0 commit comments

Comments
 (0)