Skip to content

Commit afe03e9

Browse files
feature(session): add session middleware
1 parent 3c70dcd commit afe03e9

20 files changed

+2530
-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

.github/workflows/ci-session.yml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
name: ci-session
2+
on:
3+
push:
4+
branches: [main]
5+
paths:
6+
- 'packages/session/**'
7+
pull_request:
8+
branches: ['*']
9+
paths:
10+
- 'packages/session/**'
11+
12+
jobs:
13+
ci:
14+
runs-on: ubuntu-latest
15+
steps:
16+
- uses: actions/checkout@v4
17+
- uses: actions/setup-node@v4
18+
with:
19+
node-version: 20.x
20+
- run: yarn workspaces focus hono-middleware @hono/session
21+
- run: yarn workspace @hono/session build
22+
- run: yarn workspace @hono/session publint
23+
- run: yarn workspace @hono/session typecheck
24+
- run: yarn eslint packages/session
25+
- run: yarn test --coverage --project @hono/session
26+
- uses: codecov/codecov-action@v5
27+
with:
28+
fail_ci_if_error: true
29+
directory: ./coverage
30+
flags: session
31+
env:
32+
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

packages/session/README.md

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
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` or [`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+
## Example
74+
75+
```ts
76+
import { session } from '@hono/session'
77+
import { Hono } from 'hono'
78+
79+
const app = new Hono()
80+
81+
app.use(session()).get('/', async (c) => {
82+
const data = await c.var.session.get()
83+
return c.json(data)
84+
})
85+
86+
export default app
87+
```
88+
89+
### With Session storage
90+
91+
```ts
92+
import { session, sessionStorage } from '@hono/session'
93+
import { Hono } from 'hono'
94+
95+
const app = new Hono()
96+
97+
app
98+
.use(
99+
sessionStorage({
100+
delete(sid) {},
101+
async get(sid) {},
102+
set(sid, value) {},
103+
}),
104+
session()
105+
)
106+
.get('/', async (c) => {
107+
const data = await c.var.session.get()
108+
return c.json(data)
109+
})
110+
111+
export default app
112+
```
113+
114+
See also:
115+
116+
- [Cloudflare KV as session storage](./examples/cloudflare-kv.ts)
117+
- [Using Unstorage as session storage](./examples/unstorage.ts)
118+
119+
## Author
120+
121+
Jonathan haines <https://github.com/barrythepenguin>
122+
123+
## License
124+
125+
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 { session, sessionStorage } 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+
sessionStorage((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+
session({ 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+
})
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { Hono } from 'hono'
2+
import { createStorage } from 'unstorage'
3+
import type { SessionData } from '../src'
4+
import { session, sessionStorage } from '../src'
5+
import * as cookies from '../src/cookies'
6+
7+
interface StorageData extends SessionData {
8+
sub: string
9+
}
10+
11+
export const storage = createStorage<StorageData>()
12+
13+
export const secret = cookies.generateId(16)
14+
15+
/**
16+
* Example hono app using Unstorage as session storage.
17+
*
18+
* @see {@link https://unstorage.unjs.io/}
19+
*/
20+
export const app = new Hono()
21+
.use(
22+
sessionStorage({
23+
delete(sid) {
24+
storage.remove(sid)
25+
},
26+
get(sid) {
27+
return storage.get(sid)
28+
},
29+
set(sid, data) {
30+
storage.set(sid, data)
31+
},
32+
}),
33+
session({ secret })
34+
)
35+
.get('/session', async (c) => {
36+
const data = await c.var.session.get()
37+
return c.json(data)
38+
})
39+
.put('/session', async (c) => {
40+
const data = await c.req.json()
41+
await c.var.session.update(data)
42+
return c.json(c.var.session.data)
43+
})
44+
.delete('/session', async (c) => {
45+
await c.var.session.get()
46+
c.var.session.delete()
47+
return c.body(null, 204)
48+
})

0 commit comments

Comments
 (0)