Skip to content

Commit e93980f

Browse files
feat: Add Express session manager with splitting support
- Implemented ExpressStore for session management in Express environments. - Added logic to split large session values to avoid cookie size limits, and reassemble them on retrieval. - Included comprehensive unit tests for the new store, including tests for the splitting logic. - Moved @types/express to devDependencies and created a separate 'express' entry point to prevent type conflicts in non-Express projects.
1 parent ea118ec commit e93980f

File tree

7 files changed

+371
-20
lines changed

7 files changed

+371
-20
lines changed

lib/main.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ describe("index exports", () => {
6060
"LocalStorage",
6161
"storageSettings",
6262
"ExpoSecureStore",
63+
"ExpressStore",
6364

6465
// token utils
6566
"getActiveStorage",

lib/main.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export {
5757
ChromeStore,
5858
LocalStorage,
5959
StorageKeys,
60+
ExpressStore,
6061
} from "./sessionManager";
6162

6263
// This export provides an implementation of SessionManager<V>

lib/sessionManager/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export { MemoryStorage } from "./stores/memory.js";
2525
export { ChromeStore } from "./stores/chromeStore.js";
2626
export { ExpoSecureStore } from "./stores/expoSecureStore.js";
2727
export { LocalStorage } from "./stores/localStorage.ts";
28+
export { ExpressStore } from "./stores/expressStore.ts";
2829

2930
// Export types directly
3031
export { StorageKeys } from "./types.ts";
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { describe, it, expect, beforeEach, vi } from "vitest";
2+
import { ExpressStore } from "../../main";
3+
import { StorageKeys } from "../types";
4+
import type { Request } from "express";
5+
import { storageSettings } from "..";
6+
7+
const mockRequest = (
8+
sessionData: Record<string, unknown> | null,
9+
destroyError: Error | null = null,
10+
) => {
11+
const session = sessionData
12+
? {
13+
...sessionData,
14+
destroy: vi.fn((callback: (err: Error | null) => void) => {
15+
callback(destroyError);
16+
}),
17+
}
18+
: undefined;
19+
20+
return {
21+
session,
22+
} as unknown as Request;
23+
};
24+
25+
describe("ExpressStore", () => {
26+
let req: Request;
27+
let sessionManager: ExpressStore;
28+
29+
describe("constructor", () => {
30+
it("should throw an error if session is not available on the request", () => {
31+
req = mockRequest(null);
32+
expect(() => new ExpressStore(req)).toThrow(
33+
"Session not available on the request. Please ensure the 'express-session' middleware is configured and running before the Kinde middleware.",
34+
);
35+
});
36+
37+
it("should not throw an error if session is available on the request", () => {
38+
req = mockRequest({});
39+
expect(() => new ExpressStore(req)).not.toThrow();
40+
});
41+
});
42+
43+
describe("with a valid session", () => {
44+
const keyPrefix = storageSettings.keyPrefix;
45+
beforeEach(() => {
46+
const initialSession = {
47+
[`${keyPrefix}${StorageKeys.accessToken}0`]: "access-token",
48+
[`${keyPrefix}${StorageKeys.idToken}0`]: "id-token",
49+
};
50+
req = mockRequest(initialSession);
51+
sessionManager = new ExpressStore(req);
52+
});
53+
54+
it("should get an item from the session", async () => {
55+
const accessToken = await sessionManager.getSessionItem(
56+
StorageKeys.accessToken,
57+
);
58+
expect(accessToken).toBe("access-token");
59+
});
60+
61+
it("should return null for a non-existent item", async () => {
62+
const refreshToken = await sessionManager.getSessionItem(
63+
StorageKeys.refreshToken,
64+
);
65+
expect(refreshToken).toBeNull();
66+
});
67+
68+
it("should set an item in the session", async () => {
69+
await sessionManager.setSessionItem(
70+
StorageKeys.refreshToken,
71+
"refresh-token",
72+
);
73+
expect(req.session![`${keyPrefix}${StorageKeys.refreshToken}0`]).toBe(
74+
"refresh-token",
75+
);
76+
});
77+
78+
it("should remove an item from the session", async () => {
79+
await sessionManager.removeSessionItem(StorageKeys.accessToken);
80+
expect(
81+
req.session![`${keyPrefix}${StorageKeys.accessToken}0`],
82+
).toBeUndefined();
83+
});
84+
85+
it("should destroy the session", async () => {
86+
await sessionManager.destroySession();
87+
expect(req.session!.destroy).toHaveBeenCalled();
88+
});
89+
90+
it("should reject with an error if destroying the session fails", async () => {
91+
const error = new Error("Failed to destroy Kinde session");
92+
req = mockRequest({}, error);
93+
sessionManager = new ExpressStore(req);
94+
await expect(sessionManager.destroySession()).rejects.toThrow(error);
95+
});
96+
});
97+
98+
describe("splitting and reassembly logic", () => {
99+
const longString = "a".repeat(5000); // longer than default maxLength (2000)
100+
const keyPrefix = storageSettings.keyPrefix;
101+
const maxLength = storageSettings.maxLength;
102+
let req: Request;
103+
let sessionManager: ExpressStore;
104+
105+
beforeEach(() => {
106+
req = mockRequest({});
107+
sessionManager = new ExpressStore(req);
108+
});
109+
110+
it("should split and store a long string value across multiple session keys", async () => {
111+
await sessionManager.setSessionItem(StorageKeys.state, longString);
112+
expect(req.session![`${keyPrefix}state0`]).toBe(
113+
longString.slice(0, maxLength),
114+
);
115+
expect(req.session![`${keyPrefix}state1`]).toBe(
116+
longString.slice(maxLength, maxLength * 2),
117+
);
118+
expect(req.session![`${keyPrefix}state2`]).toBe(
119+
longString.slice(maxLength * 2),
120+
);
121+
expect(req.session![`${keyPrefix}state3`]).toBeUndefined();
122+
});
123+
124+
it("should reassemble a long string value from multiple session keys", async () => {
125+
// Simulate split storage
126+
req.session![`${keyPrefix}state0`] = longString.slice(0, maxLength);
127+
req.session![`${keyPrefix}state1`] = longString.slice(
128+
maxLength,
129+
maxLength * 2,
130+
);
131+
req.session![`${keyPrefix}state2`] = longString.slice(maxLength * 2);
132+
const value = await sessionManager.getSessionItem(StorageKeys.state);
133+
expect(value).toBe(longString);
134+
});
135+
136+
it("should remove all split keys for a long string value", async () => {
137+
req.session![`${keyPrefix}state0`] = "part1";
138+
req.session![`${keyPrefix}state1`] = "part2";
139+
req.session![`${keyPrefix}state2`] = "part3";
140+
await sessionManager.removeSessionItem(StorageKeys.state);
141+
expect(req.session![`${keyPrefix}state0`]).toBeUndefined();
142+
expect(req.session![`${keyPrefix}state1`]).toBeUndefined();
143+
expect(req.session![`${keyPrefix}state2`]).toBeUndefined();
144+
});
145+
146+
it("should store and retrieve non-string values without splitting", async () => {
147+
const obj = { foo: "bar" };
148+
await sessionManager.setSessionItem(StorageKeys.nonce, obj);
149+
expect(req.session![`${keyPrefix}nonce0`]).toEqual(obj);
150+
const value = await sessionManager.getSessionItem(StorageKeys.nonce);
151+
expect(value).toEqual(obj); // Should return the original object
152+
});
153+
});
154+
});
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import type { Request } from "express";
2+
import { SessionBase, StorageKeys, type SessionManager } from "../types.js";
3+
import { storageSettings } from "../index.js";
4+
import { splitString } from "../../utils/splitString.js";
5+
6+
declare global {
7+
// eslint-disable-next-line @typescript-eslint/no-namespace
8+
namespace Express {
9+
interface Request {
10+
session?: {
11+
[key: string]: unknown;
12+
destroy: (callback: (err?: Error | null) => void) => void;
13+
};
14+
}
15+
}
16+
}
17+
18+
/**
19+
* Provides an Express session-based session manager.
20+
* This class acts as a structured interface to the 'req.session' object,
21+
* that is populated by the express-session middleware.
22+
* @class ExpressStore
23+
*/
24+
export class ExpressStore<V extends string = StorageKeys>
25+
extends SessionBase<V>
26+
implements SessionManager<V>
27+
{
28+
/**
29+
* The Express req obj which holds the session's data
30+
*/
31+
private req: Request;
32+
33+
constructor(req: Request) {
34+
super();
35+
if (!req.session) {
36+
throw new Error(
37+
"Session not available on the request. Please ensure the 'express-session' middleware is configured and running before the Kinde middleware.",
38+
);
39+
}
40+
this.req = req;
41+
}
42+
43+
/**
44+
* Gets a value from the Express session.
45+
* @param {string} itemKey
46+
* @returns {Promise<unknown | null>}
47+
*/
48+
async getSessionItem(itemKey: V | StorageKeys): Promise<unknown | null> {
49+
// Reassemble split string values if present
50+
const baseKey = `${storageSettings.keyPrefix}${String(itemKey)}`;
51+
if (this.req.session![`${baseKey}0`] === undefined) {
52+
return null;
53+
}
54+
55+
// if under settingConfig maxLength - return as-is
56+
if (this.req.session![`${baseKey}1`] === undefined) {
57+
return this.req.session![`${baseKey}0`];
58+
}
59+
60+
// Multiple items exist, concatenate them as strings (for split strings)
61+
let itemValue = "";
62+
let index = 0;
63+
let key = `${baseKey}${index}`;
64+
while (this.req.session![key] !== undefined) {
65+
itemValue += this.req.session![key] as string;
66+
index++;
67+
key = `${baseKey}${index}`;
68+
}
69+
return itemValue;
70+
}
71+
72+
/**
73+
* Sets a value in the Express session.
74+
* @param {string} itemKey
75+
* @param {unknown} itemValue
76+
* @returns {Promise<void>}
77+
*/
78+
async setSessionItem(
79+
itemKey: V | StorageKeys,
80+
itemValue: unknown,
81+
): Promise<void> {
82+
// Remove any existing split items first
83+
await this.removeSessionItem(itemKey);
84+
const baseKey = `${storageSettings.keyPrefix}${String(itemKey)}`;
85+
if (typeof itemValue === "string") {
86+
splitString(itemValue, storageSettings.maxLength).forEach(
87+
(splitValue, index) => {
88+
this.req.session![`${baseKey}${index}`] = splitValue;
89+
},
90+
);
91+
return;
92+
}
93+
this.req.session![`${baseKey}0`] = itemValue;
94+
}
95+
96+
/**
97+
* Removes a value from the Express session.
98+
* @param {string} itemKey
99+
* @returns {Promise<void>}
100+
*/
101+
async removeSessionItem(itemKey: V | StorageKeys): Promise<void> {
102+
// Remove all items with the key prefix
103+
const baseKey = `${storageSettings.keyPrefix}${String(itemKey)}`;
104+
for (const key in this.req.session!) {
105+
if (key.startsWith(baseKey)) {
106+
delete this.req.session![key];
107+
}
108+
}
109+
}
110+
111+
/**
112+
* Clears the entire Express session.
113+
* @returns {Promise<void>}
114+
*/
115+
async destroySession(): Promise<void> {
116+
return new Promise((resolve, reject) => {
117+
this.req.session!.destroy((err) => {
118+
if (err) {
119+
return reject(err);
120+
}
121+
resolve();
122+
});
123+
});
124+
}
125+
}

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@
5151
"vite": "^6.0.0",
5252
"vite-plugin-dts": "^4.0.3",
5353
"vitest": "^3.0.0",
54-
"vitest-fetch-mock": "^0.4.1"
54+
"vitest-fetch-mock": "^0.4.1",
55+
"@types/express": "^4.17.0"
5556
},
5657
"peerDependencies": {
5758
"expo-secure-store": ">=11.0.0"
@@ -65,4 +66,4 @@
6566
"dependencies": {
6667
"@kinde/jwt-decoder": "^0.2.0"
6768
}
68-
}
69+
}

0 commit comments

Comments
 (0)