Skip to content

Commit 18b1ebc

Browse files
feat(session): add session middleware (#1188)
* feature(session): add session middleware * refactor: replace @panva/hkdf with web crypto * refactor: export SessionEnv * chore: unify handler definition --------- Co-authored-by: Yusuke Wada <yusuke@kamawada.com>
1 parent 1781897 commit 18b1ebc

19 files changed

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