@scayle/h3-session 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,47 @@
1
+ # @scayle/h3-session
2
+
3
+ ## 0.3.4
4
+
5
+ ### Patch Changes
6
+
7
+ - Add license file
8
+
9
+ ## 0.3.3
10
+
11
+ ### Patch Changes
12
+
13
+ - New cookie settings should apply to existing cookies
14
+
15
+ ## 0.3.2
16
+
17
+ ### Patch Changes
18
+
19
+ - Fix loading sessions created by express-session
20
+
21
+ ## 0.3.1
22
+
23
+ ### Patch Changes
24
+
25
+ - raise required h3 peerDependency version to v1.8.0 and use only fixed dependencies
26
+
27
+ ## 0.3.0
28
+
29
+ ### Minor Changes
30
+
31
+ - Support typing the session data
32
+
33
+ ### Patch Changes
34
+
35
+ - Remove unused proxy option
36
+
37
+ ## 0.2.1
38
+
39
+ ### Patch Changes
40
+
41
+ - Bump the compilation target to ES2022
42
+
43
+ ## 0.2.0
44
+
45
+ ### Minor Changes
46
+
47
+ - Add h3-session package
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 SCAYLE GmbH
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,150 @@
1
+ # @scayle/h3-session
2
+
3
+ [![npm version][npm-version-src]][npm-version-href]
4
+ [![npm downloads][npm-downloads-src]][npm-downloads-href]
5
+ [![License][license-src]][license-href]
6
+
7
+ Persistent sessions for [h3](https://github.com/unjs/h3). Based on [express-session](https://www.npmjs.com/package/express-session).
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ # Using pnpm
13
+ pnpm add @scayle/h3-session
14
+
15
+ # Using yarn
16
+ yarn add @scayle/h3-session
17
+
18
+ # Using npm
19
+ npm install @scayle/h3-session
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ ```ts
25
+ import { useSession, UnstorageSessionStore } from '@scayle/h3-session'
26
+ import { createStorage } from 'unstorage'
27
+
28
+ const DEFAULT_TTL = 60 * 60 * 24
29
+
30
+ const redisStore = UnstorageSessionStore(
31
+ createStorage({
32
+ driver: redisDriver({
33
+ host: 'localhost',
34
+ port: 6379,
35
+ }),
36
+ }),
37
+ {
38
+ ttl: DEFAULT_TTL,
39
+ },
40
+ )
41
+
42
+ export default defineEventHandler(async (event) => {
43
+ await useSession(event, {
44
+ store: redisStore,
45
+ secret: 'my-secret',
46
+ })
47
+
48
+ const sessionId = event.context.sessionId
49
+ const sessionData = event.context.session.data
50
+ })
51
+ ```
52
+
53
+ ## useSession options
54
+
55
+ The following options can be passed as the second argument to `useSession`. `store` and `secret` must be defined.
56
+
57
+ ```ts
58
+ interface H3SessionOptions {
59
+ // Where session data will be stored
60
+ store: SessionStore
61
+
62
+ // Settings for configuring the session cookie
63
+ // Cookies are serialized with [`cookie-es`](https://github.com/unjs/cookie-es).
64
+ // The default value is { path: '/', httpOnly: true, secure: true, maxAge: null }.
65
+ cookie?: {
66
+ domain?: string
67
+ expires?: Date
68
+ httpOnly?: boolean
69
+ maxAge?: number
70
+ path?: string
71
+ sameSite?: true | false | 'lax' | 'none' | 'strict'
72
+ secure?: boolean
73
+ }
74
+
75
+ // The name of the session cookie. Defaults to 'connect.sid'
76
+ name?: string
77
+
78
+ // Function to generate a session ID. Defaults to randomUUID
79
+ genid?: (event: H3Event) => string
80
+
81
+ // Function to generate new session data
82
+ generate?: () => SessionDataT
83
+
84
+ // Should the session be automatically saved on initialization?
85
+ saveUninitialized?: boolean
86
+
87
+ // Secret(s) to use for cookie signing
88
+ secret: string | string[]
89
+ }
90
+ ```
91
+
92
+ ## Session object
93
+
94
+ `useSession` attaches a `Session` object to `event.context.session`. The `Session` object has several methods along with an `id` property containing the session's ID, a `data` property which contains the session's data and a `cookie` property representing the session's cookie.
95
+
96
+ ### Session.save()
97
+
98
+ Save the session to the store. Unlike `express-session`, session data is not saved automatically when it is changed, so this function should be called after modifying the session or at the end of the request.
99
+
100
+ ### Session.reload()
101
+
102
+ Reload the session's data from the store.
103
+
104
+ ### Session.destroy()
105
+
106
+ Destroy the session by removing its data from the store and deleting the cookie.
107
+
108
+ ### Session.regenerate()
109
+
110
+ Recreate the session with a new ID and empty data.
111
+
112
+ ## SessionCookie
113
+
114
+ The `cookie` property on the session can be used to control the session's cookie. Any of the cookie serialization options such as `maxAge` or `domain` can be edited and will affect the `Set-Cookie` header sent in the response. Additionally, `setSessionId(sid: string)` can be called to update the cookie's value with a new session ID.
115
+
116
+ ## SessionStore
117
+
118
+ Any object implementing the `SessionStore` interface can be used as the `store` option.
119
+
120
+ There is an included `UnstorageSessionStore` class to create `SessionStore` backed by an [`unstorage`](https://unstorage.unjs.io/) provider. To avoid accumulating dead sessions, use a driver (such as redis) which supports the `ttl` option.
121
+
122
+ A reference to the store is added to `event.context.sessionStore` to enable manual management of the session data. (e.g. clearing all saved sessions)
123
+
124
+ ## Cookie signing
125
+
126
+ The value of the session cookie is a signed variant of the session's ID. The format is `s:[sessionID].[signature]`. e.g. `s:7247d6e9-8ddb-4005-988b-9aa82bd7d6d5.lERU16bdv6tojUNeM8V2UOARyNxHNaWKIYi4bLRxx7o`. This matches the format used by `express-session`, so existing sessions can be preserved when migrating.
127
+
128
+ The secret used for signing cookies is set in the options of `useSession`. It can be a `string` or `string[]`. When specified as an array, the last item will be used for signing new session cookies, but all secrets will be used to verify existing cookies. This allows changing the signing secret without invalidating existing sessions.
129
+
130
+ ## Typing
131
+
132
+ The type of the session data can be specified by augmenting the `SessionDataT` interface.
133
+
134
+ ```ts
135
+ declare module '@scayle/h3-session' {
136
+ export interface SessionDataT {
137
+ userId: string
138
+ token: string
139
+ }
140
+ }
141
+ ```
142
+
143
+ <!-- Badges -->
144
+
145
+ [npm-version-src]: https://img.shields.io/npm/v/@scayle/h3-session/latest.svg?style=flat&colorA=18181B&colorB=28CF8D
146
+ [npm-version-href]: https://npmjs.com/package/@scayle/h3-session
147
+ [npm-downloads-src]: https://img.shields.io/npm/dm/@scayle/h3-session.svg?style=flat&colorA=18181B&colorB=28CF8D
148
+ [npm-downloads-href]: https://npmjs.com/package/@scayle/h3-session
149
+ [license-src]: https://img.shields.io/npm/l/@scayle/h3-session.svg?style=flat&colorA=18181B&colorB=28CF8D
150
+ [license-href]: https://npmjs.com/package/@scayle/h3-session
package/dist/index.js ADDED
@@ -0,0 +1,158 @@
1
+ // A session implementation for h3 based on express-session
2
+ import { getCookie, setCookie } from 'h3';
3
+ import { defu } from 'defu';
4
+ import { subtle, randomUUID } from 'uncrypto';
5
+ import { Session } from './session';
6
+ /**
7
+ * Verify that a cookie was signed with one of the secrets
8
+ * If it's valid, return the embedded message
9
+ *
10
+ * @param value a cookie value in the format `s:[value].[signature]`
11
+ * @param secrets an array of secret strings to verify with
12
+ */
13
+ async function unsignCookie(value, secrets) {
14
+ // Validate cookie format
15
+ const matches = /s:([^.]*)\.(.*)/.exec(value);
16
+ if (!matches) {
17
+ return false;
18
+ }
19
+ const [, message, signature] = matches;
20
+ const encoder = new TextEncoder();
21
+ const messageUint8Array = encoder.encode(message);
22
+ const signatureUint8Array = Uint8Array.from(Buffer.from(signature, 'base64'));
23
+ for (let i = 0; i < secrets.length; i++) {
24
+ const keyUint8Array = encoder.encode(secrets[i]);
25
+ const cryptoKey = await subtle.importKey('raw', keyUint8Array, { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
26
+ const result = await subtle.verify('HMAC', cryptoKey, signatureUint8Array, messageUint8Array);
27
+ if (result) {
28
+ return message;
29
+ }
30
+ }
31
+ return false;
32
+ }
33
+ /**
34
+ * Creates a signed cookie value in the format `s:[value].[signature]`
35
+ * @param value
36
+ * @param secret
37
+ */
38
+ async function signCookie(value, secret) {
39
+ // Convert the value and secret to Uint8Array
40
+ const encoder = new TextEncoder();
41
+ const messageUint8Array = encoder.encode(value);
42
+ const keyUint8Array = encoder.encode(secret);
43
+ // Import the secret as a CryptoKey
44
+ const cryptoKey = await subtle.importKey('raw', keyUint8Array, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
45
+ const signature = await subtle.sign('HMAC', cryptoKey, messageUint8Array);
46
+ // Encode in base64
47
+ const b64Signature = Buffer.from(signature)
48
+ .toString('base64')
49
+ .replace(/=+$/, '');
50
+ return `s:${value}.${b64Signature}`;
51
+ }
52
+ function validateConfig(config) {
53
+ if (!config.store) {
54
+ throw new Error('[h3-session] Session store is required!');
55
+ }
56
+ if (!config.secret) {
57
+ throw new Error('[h3-session] Session secret is required!');
58
+ }
59
+ }
60
+ /**
61
+ * Attach a session to an H3Event
62
+ * @param event
63
+ * @param config
64
+ */
65
+ export async function useSession(event, config) {
66
+ // Skip if session is already attached
67
+ if (event.context.session) {
68
+ return;
69
+ }
70
+ validateConfig(config);
71
+ // Populate default config values
72
+ const sessionConfig = defu(config, {
73
+ name: 'connect.sid',
74
+ genid: (_event) => {
75
+ return randomUUID();
76
+ },
77
+ generate: () => ({}),
78
+ cookie: { path: '/', httpOnly: true, secure: true, maxAge: null },
79
+ saveUninitialized: false,
80
+ });
81
+ const { store } = sessionConfig;
82
+ const generate = async () => {
83
+ return await Promise.resolve({
84
+ id: sessionConfig.genid(event),
85
+ data: sessionConfig.generate(),
86
+ });
87
+ };
88
+ const createSessionCookie = async (sid) => {
89
+ let signedCookie;
90
+ const cookie = {
91
+ ...sessionConfig.cookie,
92
+ // Default to a max age of one day
93
+ maxAge: sessionConfig.cookie.maxAge || 60 * 60 * 24,
94
+ setSessionId: async (sid) => {
95
+ signedCookie = await signCookie(sid, normalizedSecrets[normalizedSecrets.length - 1]);
96
+ setCookie(event, sessionConfig.name, signedCookie, cookie);
97
+ },
98
+ };
99
+ await cookie.setSessionId(sid);
100
+ // Update the Set-Cookie header whenever a property on the cookie object changes
101
+ // We can't hook into the onBeforeResponse, so this is the best alternative
102
+ return new Proxy(cookie, {
103
+ set(target, property, value) {
104
+ // @ts-ignore
105
+ target[property] = value;
106
+ setCookie(event, sessionConfig.name, signedCookie, cookie);
107
+ return true;
108
+ },
109
+ });
110
+ };
111
+ // Secret can be a string or array, normalize to an array
112
+ const normalizedSecrets = Array.isArray(sessionConfig.secret)
113
+ ? sessionConfig.secret
114
+ : [sessionConfig.secret];
115
+ // Check the request for a session cookie
116
+ const rawCookie = getCookie(event, sessionConfig.name);
117
+ // Extract the ID from the cookie
118
+ const sessionId = rawCookie
119
+ ? await unsignCookie(rawCookie, normalizedSecrets)
120
+ : null;
121
+ // Load the session data from the store
122
+ let sessionData;
123
+ if (sessionId) {
124
+ sessionData = await store.get(sessionId);
125
+ }
126
+ async function createNewSession() {
127
+ const { id, data } = await generate();
128
+ const cookie = await createSessionCookie(id);
129
+ event.context.session = new Session(id, data, store, generate, cookie);
130
+ if (sessionConfig.saveUninitialized) {
131
+ await event.context.session.save();
132
+ }
133
+ // Set sessionId on event context
134
+ event.context.sessionId = id;
135
+ }
136
+ async function createExistingSession(id, data) {
137
+ const cookie = await createSessionCookie(id);
138
+ if (store.touch) {
139
+ // touch existing sessions to refresh their TTLs
140
+ await store.touch(id, data);
141
+ }
142
+ event.context.session = new Session(id, data, store, generate, cookie);
143
+ // Set sessionId on event context
144
+ event.context.sessionId = id;
145
+ }
146
+ // Create the session data and a new ID if it does not exist
147
+ if (!sessionId || !sessionData) {
148
+ await createNewSession();
149
+ }
150
+ else {
151
+ await createExistingSession(sessionId, sessionData);
152
+ }
153
+ // expose session store
154
+ event.context.sessionStore = store;
155
+ }
156
+ export * from './session';
157
+ export * from './unstorage-store';
158
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,37 @@
1
+ export class Session {
2
+ #id;
3
+ #store;
4
+ data;
5
+ #generate;
6
+ cookie;
7
+ constructor(id, data, store, generate, cookie) {
8
+ this.data = data;
9
+ this.#id = id;
10
+ this.#store = store;
11
+ this.#generate = generate;
12
+ this.cookie = cookie;
13
+ }
14
+ get id() {
15
+ return this.#id;
16
+ }
17
+ async save() {
18
+ await this.#store.set(this.#id, { ...this.data, cookie: this.cookie });
19
+ }
20
+ async reload() {
21
+ this.data =
22
+ (await this.#store.get(this.#id)) ?? (await this.#generate()).data;
23
+ }
24
+ async destroy() {
25
+ // Delete the cookie by setting maxAge to 0
26
+ this.cookie.maxAge = 0;
27
+ await this.#store.destroy(this.#id);
28
+ }
29
+ async regenerate() {
30
+ await this.#store.destroy(this.#id);
31
+ const { data, id } = await this.#generate();
32
+ this.data = data;
33
+ this.#id = id;
34
+ await Promise.all([this.cookie.setSessionId(id), this.save()]);
35
+ }
36
+ }
37
+ //# sourceMappingURL=session.js.map
@@ -0,0 +1,111 @@
1
+ import { z } from 'zod';
2
+ const CookieSchema = z.object({
3
+ domain: z.string().optional(),
4
+ expires: z.coerce.date().optional(),
5
+ httpOnly: z.boolean().optional(),
6
+ maxAge: z.number().optional(),
7
+ path: z.string().optional(),
8
+ sameSite: z.enum(['lax', 'none', 'strict']).or(z.boolean()).optional(),
9
+ secure: z.boolean().optional(),
10
+ });
11
+ export class UnstorageSessionStore {
12
+ storage;
13
+ prefix;
14
+ ttl;
15
+ constructor(storage, options) {
16
+ this.storage = storage;
17
+ this.prefix = options?.prefix ?? 'sess';
18
+ this.ttl = options?.ttl ?? 60 * 60 * 24;
19
+ }
20
+ /**
21
+ * Destroy the session with the specified session ID
22
+ * @param sid the un-prefixed session ID
23
+ */
24
+ async destroy(sid) {
25
+ await this.storage.removeItem(this.getKey(sid));
26
+ }
27
+ /**
28
+ * Get the session with the specified session ID
29
+ * @param sid the un-prefixed session ID
30
+ */
31
+ async get(sid) {
32
+ const item = await this.storage.getItem(this.getKey(sid));
33
+ // If the cookie saved in the storage is not valid, delete it
34
+ if (item && item.cookie) {
35
+ try {
36
+ item.cookie = CookieSchema.parse(item.cookie);
37
+ }
38
+ catch (e) {
39
+ delete item.cookie;
40
+ }
41
+ }
42
+ return item ?? undefined;
43
+ }
44
+ /**
45
+ * Save the session with the specified session ID
46
+ * If the session has expired (has a TTL <= 0) it is deleted.
47
+ * @param sid the un-prefixed session ID
48
+ * @param data the session data
49
+ */
50
+ async set(sid, data) {
51
+ const ttl = this.getTTL(data);
52
+ if (ttl > 0) {
53
+ await this.storage.setItem(this.getKey(sid), data, { ttl });
54
+ }
55
+ else {
56
+ await this.destroy(sid);
57
+ }
58
+ }
59
+ /**
60
+ * Update a session's TTL
61
+ * @param sid the un-prefixed session ID
62
+ * @param data the session data
63
+ */
64
+ async touch(sid, data) {
65
+ // For now, it seems the best way to bump the TTL through the unstorage
66
+ // interface is by re-saving the data
67
+ await this.set(sid, data);
68
+ }
69
+ /**
70
+ * Remove all saved sessions
71
+ */
72
+ async clear() {
73
+ return await this.storage.clear(this.prefix);
74
+ }
75
+ /**
76
+ * Fetch all saved sessions.
77
+ */
78
+ async all() {
79
+ const keys = await this.getAllKeys();
80
+ const values = await this.storage.getItems(keys);
81
+ return values.map(({ value }) => {
82
+ return value;
83
+ });
84
+ }
85
+ /**
86
+ * Returns the number of saved sessions
87
+ */
88
+ async length() {
89
+ const keys = await this.getAllKeys();
90
+ return keys.length;
91
+ }
92
+ async getAllKeys() {
93
+ return await this.storage.getKeys(this.prefix);
94
+ }
95
+ getKey(key) {
96
+ return [this.prefix, key].join(':');
97
+ }
98
+ /**
99
+ * Get the TTL for the session, either from the TTL function if it exists
100
+ * or falling back to the default TTL value.
101
+ * @param session
102
+ * @private
103
+ */
104
+ getTTL(session) {
105
+ if (typeof this.ttl === 'function') {
106
+ return this.ttl(session);
107
+ }
108
+ return this.ttl;
109
+ }
110
+ }
111
+ //# sourceMappingURL=unstorage-store.js.map
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@scayle/h3-session",
3
+ "version": "0.3.4",
4
+ "description": "Persistent sessions for h3",
5
+ "author": "SCAYLE Commerce Engine",
6
+ "license": "MIT",
7
+ "publishConfig": {
8
+ "access": "public",
9
+ "registry": "https://registry.npmjs.org/"
10
+ },
11
+ "exports": {
12
+ ".": "./dist/index.js"
13
+ },
14
+ "main": "./dist/index.js",
15
+ "files": [
16
+ "CHANGELOG.md",
17
+ "./dist/*.js"
18
+ ],
19
+ "scripts": {
20
+ "build": "yarn tsc -p tsconfig.json",
21
+ "format": "prettier --ignore-unknown --log-level warn --check .",
22
+ "format:fix": "prettier --ignore-unknown --log-level warn --write .",
23
+ "lint": "eslint . --format gitlab",
24
+ "lint:fix": "eslint . --fix"
25
+ },
26
+ "peerDependencies": {
27
+ "h3": "^1.8.0"
28
+ },
29
+ "dependencies": {
30
+ "defu": "6.1.3",
31
+ "uncrypto": "0.1.3",
32
+ "unstorage": "1.9.0",
33
+ "zod": "3.22.4"
34
+ },
35
+ "devDependencies": {
36
+ "@changesets/types": "5.2.1",
37
+ "@scayle/eslint-config-storefront": "3.2.5",
38
+ "@scayle/prettier-config-storefront": "2.0.2",
39
+ "eslint": "8.52.0",
40
+ "eslint-formatter-gitlab": "5.0.0",
41
+ "h3": "1.8.2",
42
+ "prettier": "3.0.0",
43
+ "typescript": "5.2.2"
44
+ },
45
+ "volta": {
46
+ "node": "20.9.0"
47
+ }
48
+ }