Skip to content

Commit b7b1440

Browse files
feat: Add Express session store
Introduces `ExpressStore`, a new session manager for Express.js applications. This store leverages the `express-session` middleware to manage Kinde session data. The implementation includes logic to automatically split and reassemble large session values, accommodating session stores with size limitations. `@types/express` is added as a peer dependency to avoid version conflicts.
1 parent ea118ec commit b7b1440

File tree

7 files changed

+365
-19
lines changed

7 files changed

+365
-19
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
@@ -74,3 +74,4 @@ export const ExpoSecureStore: {
7474
};
7575

7676
export type { SessionManager } from "./sessionManager";
77+
export { ExpressStore } from "./sessionManager/stores/expressStore.js";

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 "./expressStore";
3+
import { StorageKeys } from "../types";
4+
import type { Request } from "express";
5+
import { storageSettings } from "../index.js";
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("[object Object]"); // Because getSessionItem always reassembles as string
152+
});
153+
});
154+
});
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
// @types/express is not in dev deps but in peer deps to avoid types version mismatches
2+
// when using different versions of express in the same project.
3+
import type { Request } from "express";
4+
import { SessionBase, StorageKeys, type SessionManager } from "../types.js";
5+
import { storageSettings } from "../index.js";
6+
import { splitString } from "../../utils/splitString.js";
7+
8+
declare global {
9+
// eslint-disable-next-line @typescript-eslint/no-namespace
10+
namespace Express {
11+
interface Request {
12+
session?: {
13+
[key: string]: unknown;
14+
destroy: (callback: (err?: Error | null) => void) => void;
15+
};
16+
}
17+
}
18+
}
19+
20+
/**
21+
* Provides an Express session-based session manager.
22+
* This class acts as a structured interface to the 'req.session' object,
23+
* that is populated by the express-session middleware.
24+
* @class ExpressStore
25+
*/
26+
export class ExpressStore<V extends string = StorageKeys>
27+
extends SessionBase<V>
28+
implements SessionManager<V>
29+
{
30+
/**
31+
* The Express req obj which holds the session's data
32+
*/
33+
private req: Request;
34+
35+
constructor(req: Request) {
36+
super();
37+
if (!req.session) {
38+
throw new Error(
39+
"Session not available on the request. Please ensure the 'express-session' middleware is configured and running before the Kinde middleware.",
40+
);
41+
}
42+
this.req = req;
43+
}
44+
45+
/**
46+
* Gets a value from the Express session.
47+
* @param {string} itemKey
48+
* @returns {Promise<unknown | null>}
49+
*/
50+
async getSessionItem(itemKey: V | StorageKeys): Promise<unknown | null> {
51+
// Reassemble split string values if present
52+
const baseKey = `${storageSettings.keyPrefix}${String(itemKey)}`;
53+
if (this.req.session![`${baseKey}0`] === undefined) {
54+
return null;
55+
}
56+
let itemValue = "";
57+
let index = 0;
58+
let key = `${baseKey}${index}`;
59+
while (this.req.session![key] !== undefined) {
60+
itemValue += this.req.session![key] as string;
61+
index++;
62+
key = `${baseKey}${index}`;
63+
}
64+
return itemValue;
65+
}
66+
67+
/**
68+
* Sets a value in the Express session.
69+
* @param {string} itemKey
70+
* @param {unknown} itemValue
71+
* @returns {Promise<void>}
72+
*/
73+
async setSessionItem(
74+
itemKey: V | StorageKeys,
75+
itemValue: unknown,
76+
): Promise<void> {
77+
// Remove any existing split items first
78+
await this.removeSessionItem(itemKey);
79+
const baseKey = `${storageSettings.keyPrefix}${String(itemKey)}`;
80+
if (typeof itemValue === "string") {
81+
splitString(itemValue, storageSettings.maxLength).forEach(
82+
(splitValue, index) => {
83+
this.req.session![`${baseKey}${index}`] = splitValue;
84+
},
85+
);
86+
return;
87+
}
88+
this.req.session![`${baseKey}0`] = itemValue;
89+
}
90+
91+
/**
92+
* Removes a value from the Express session.
93+
* @param {string} itemKey
94+
* @returns {Promise<void>}
95+
*/
96+
async removeSessionItem(itemKey: V | StorageKeys): Promise<void> {
97+
// Remove all items with the key prefix
98+
const baseKey = `${storageSettings.keyPrefix}${String(itemKey)}`;
99+
for (const key in this.req.session!) {
100+
if (key.startsWith(baseKey)) {
101+
delete this.req.session![key];
102+
}
103+
}
104+
}
105+
106+
/**
107+
* Clears the entire Express session.
108+
* @returns {Promise<void>}
109+
*/
110+
async destroySession(): Promise<void> {
111+
return new Promise((resolve, reject) => {
112+
this.req.session!.destroy((err) => {
113+
if (err) {
114+
return reject(err);
115+
}
116+
resolve();
117+
});
118+
});
119+
}
120+
}

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@
5454
"vitest-fetch-mock": "^0.4.1"
5555
},
5656
"peerDependencies": {
57-
"expo-secure-store": ">=11.0.0"
57+
"expo-secure-store": ">=11.0.0",
58+
"@types/express": "^4.17.0"
5859
},
5960
"peerDependenciesMeta": {
6061
"expo-secure-store": {

0 commit comments

Comments
 (0)