Skip to content

Commit 1a85b15

Browse files
feature(session): add session middleware
1 parent a7ee664 commit 1a85b15

19 files changed

+2617
-5
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: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
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+
| `sessionCookie`? | [`SessionCookieOptions`](#SessionCookieOptions) | Session cookie options |
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+
## `SessionCookieOptions`
49+
50+
> [!IMPORTANT]
51+
> By default, session cookies do not expire.
52+
> It is recommended to provide value for `duration.absolute`
53+
54+
### Properties
55+
56+
| Property | Type | Description |
57+
| ----------- | --------------------------------------------------------------- | --------------------------------------------------------------------------------- |
58+
| `duration`? | [`MaxAgeDuration`](#MaxAgeDuration) | The maximum age duration of the session cookie. By default, no maximum age is set |
59+
| `name`? | `string` | The name of the session cookie. Defaults to `sid` |
60+
| `options`? | [`CookieOptions`](https://hono.dev/docs/helpers/cookie#options) | Session cookie options |
61+
62+
## `MaxAgeDuration`
63+
64+
See [Session lifetime](https://thecopenhagenbook.com/sessions#session-lifetime)
65+
66+
### Properties
67+
68+
| Property | Type | Description |
69+
| ------------- | -------- | ---------------------------------------------------------------------------------------------------------------- |
70+
| `absolute` | `number` | Duration in seconds a session will be valid for, after which it will be expired and have to be re-authenticated. |
71+
| `inactivity`? | `number` | Duration in seconds a session will be considered active, during which the session max age can be extended. |
72+
73+
## `Session<Data>`
74+
75+
### Properties
76+
77+
| Property | Type | Description |
78+
| --------------- | ---------------- | --------------------- |
79+
| readonly `data` | `Data` \| `null` | Current session data. |
80+
81+
### Methods
82+
83+
#### delete()
84+
85+
delete(): `void`
86+
87+
Delete the current session, removing the session cookie and data from storage.
88+
89+
#### Returns
90+
91+
`void`
92+
93+
### get()
94+
95+
get(`refresh`): `Promise`<`Data` | `null`>
96+
97+
Get the current session data, optionally calling the provided refresh function.
98+
99+
#### Parameters
100+
101+
| Parameter | Type | Description |
102+
| ---------- | ------------------------------- | -------------------------- |
103+
| `refresh`? | [`Refresh<Data>`](#refreshdata) | Optional refresh function. |
104+
105+
### Returns
106+
107+
`Promise`<`Data` | `null`>
108+
109+
## `Refresh<Data>`
110+
111+
refresh(`expired`) => `Promise`<`Data` | `null`>
112+
113+
Function to refresh the session data. If the refresh function returns null, the session will be destroyed.
114+
115+
#### Parameters
116+
117+
| Parameter | Type | Description |
118+
| --------- | ---------------- | ------------------- |
119+
| `expired` | `Data` \| `null` | Expire session data |
120+
121+
#### Returns
122+
123+
`Data` | `null`
124+
125+
### update()
126+
127+
update(`data`): `Promise`<`void`>
128+
129+
Update the current session with the provided session data.
130+
131+
#### Parameters
132+
133+
| Parameter | Type | Description |
134+
| --------- | --------------------------------------- | ----------------------------------- |
135+
| `data` | `Data` \| [`Update<Data>`](#updatedata) | New data or function to update data |
136+
137+
#### Returns
138+
139+
`Promise`<`void`>
140+
141+
## `Update<Data>`
142+
143+
update(`prevData`) => `Data`
144+
145+
Function to update previous session data.
146+
147+
#### Parameters
148+
149+
| Parameter | Type | Description |
150+
| ---------- | ---------------- | --------------------- |
151+
| `prevData` | `Data` \| `null` | Previous session data |
152+
153+
#### Returns
154+
155+
`Data`
156+
157+
## Example
158+
159+
```ts
160+
import { useSession } from '@hono/session'
161+
import { Hono } from 'hono'
162+
163+
const app = new Hono()
164+
165+
app.use(useSession()).get('/', async (c) => {
166+
const data = await c.var.session.get()
167+
return c.json(data)
168+
})
169+
170+
export default app
171+
```
172+
173+
### With Session storage
174+
175+
```ts
176+
import { useSession, useSessionStorage } from '@hono/session'
177+
import { Hono } from 'hono'
178+
179+
const app = new Hono()
180+
181+
app
182+
.use(
183+
useSessionStorage({
184+
delete(sid) {},
185+
async get(sid) {},
186+
set(sid, value) {},
187+
}),
188+
useSession()
189+
)
190+
.get('/', async (c) => {
191+
const data = await c.var.session.get()
192+
return c.json(data)
193+
})
194+
195+
export default app
196+
```
197+
198+
See also:
199+
200+
- [Cloudflare KV as session storage](./examples/cloudflare-kv.ts)
201+
- [Using Unstorage as session storage](./examples/unstorage.ts)
202+
203+
## Author
204+
205+
Jonathan haines <https://github.com/barrythepenguin>
206+
207+
## License
208+
209+
MIT
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
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+
12+
const res = await app.request(
13+
'/session',
14+
{
15+
headers: { cookie: `sid=${await encrypt({ iat: recent, exp: soon, sid })}` },
16+
},
17+
env,
18+
ctx
19+
)
20+
await waitOnExecutionContext(ctx)
21+
const data = await res.json()
22+
23+
expect(res.status).toBe(200)
24+
expect(data).toStrictEqual({ sub })
25+
})
26+
27+
it('updates session data', async () => {
28+
const ctx = createExecutionContext()
29+
await env.SESSION_KV.put(sid, JSON.stringify({ sub }))
30+
const newSub = 'new-subject'
31+
const res = await app.request(
32+
'/session',
33+
{
34+
body: JSON.stringify({ sub: newSub }),
35+
headers: { cookie: `sid=${await encrypt({ iat: recent, exp: soon, sid })}` },
36+
method: 'PUT',
37+
},
38+
env,
39+
ctx
40+
)
41+
await waitOnExecutionContext(ctx)
42+
const data = await res.json()
43+
44+
expect(res.status).toBe(200)
45+
expect(data).toStrictEqual({ sub: newSub })
46+
await expect(env.SESSION_KV.get(sid, 'json')).resolves.toStrictEqual({ sub: newSub })
47+
})
48+
49+
it('deletes session data', async () => {
50+
const ctx = createExecutionContext()
51+
await env.SESSION_KV.put(sid, JSON.stringify({ sub }))
52+
const res = await app.request(
53+
'/session',
54+
{
55+
headers: { cookie: `sid=${await encrypt({ iat: recent, exp: soon, sid })}` },
56+
method: 'DELETE',
57+
},
58+
env,
59+
ctx
60+
)
61+
await waitOnExecutionContext(ctx)
62+
63+
expect(res.status).toBe(204)
64+
await expect(env.SESSION_KV.get(sid, 'json')).resolves.toBeNull()
65+
})
66+
})
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: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
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 res = await app.request('/session', {
10+
headers: { cookie: `sid=${await encrypt({ iat: recent, exp: soon, sid })}` },
11+
})
12+
const data = await res.json()
13+
14+
expect(res.status).toBe(200)
15+
expect(data).toStrictEqual({ sub })
16+
})
17+
18+
it('updates session data', async () => {
19+
await storage.set(sid, { sub })
20+
const newSub = 'new-subject'
21+
const res = await app.request('/session', {
22+
body: JSON.stringify({ sub: newSub }),
23+
headers: { cookie: `sid=${await encrypt({ iat: recent, exp: soon, sid })}` },
24+
method: 'PUT',
25+
})
26+
const data = await res.json()
27+
28+
expect(res.status).toBe(200)
29+
expect(data).toStrictEqual({ sub: newSub })
30+
await expect(storage.get(sid)).resolves.toStrictEqual({ sub: newSub })
31+
})
32+
33+
it('deletes session data', async () => {
34+
await storage.set(sid, { sub })
35+
const res = await app.request('/session', {
36+
headers: { cookie: `sid=${await encrypt({ iat: recent, exp: soon, sid })}` },
37+
method: 'DELETE',
38+
})
39+
40+
expect(res.status).toBe(204)
41+
await expect(storage.get(sid)).resolves.toBeNull()
42+
})
43+
})

0 commit comments

Comments
 (0)