diff --git a/lib/main.test.ts b/lib/main.test.ts index 1b932ca..bcafb08 100644 --- a/lib/main.test.ts +++ b/lib/main.test.ts @@ -69,6 +69,7 @@ describe("index exports", () => { "LocalStorage", "storageSettings", "ExpoSecureStore", + "ExpressStore", // token utils "getActiveStorage", diff --git a/lib/main.ts b/lib/main.ts index f774b47..3e01b14 100644 --- a/lib/main.ts +++ b/lib/main.ts @@ -84,6 +84,7 @@ export { StorageKeys, SessionBase, TimeoutActivityType, + ExpressStore, } from "./sessionManager"; // This export provides an implementation of SessionManager diff --git a/lib/sessionManager/index.ts b/lib/sessionManager/index.ts index 1bda918..45053fd 100644 --- a/lib/sessionManager/index.ts +++ b/lib/sessionManager/index.ts @@ -46,6 +46,7 @@ export { MemoryStorage } from "./stores/memory.js"; export { ChromeStore } from "./stores/chromeStore.js"; export { ExpoSecureStore } from "./stores/expoSecureStore.js"; export { LocalStorage } from "./stores/localStorage.ts"; +export { ExpressStore } from "./stores/expressStore.ts"; // Export types directly export { StorageKeys, SessionBase, TimeoutActivityType } from "./types.ts"; diff --git a/lib/sessionManager/stores/expressStore.test.ts b/lib/sessionManager/stores/expressStore.test.ts new file mode 100644 index 0000000..e88a9bb --- /dev/null +++ b/lib/sessionManager/stores/expressStore.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { ExpressStore } from "../../main"; +import { StorageKeys } from "../types"; +import type { Request } from "express"; +import { storageSettings } from ".."; + +const mockRequest = ( + sessionData: Record | null, + destroyError: Error | null = null, +) => { + const session = sessionData + ? { + ...sessionData, + destroy: vi.fn((callback: (err: Error | null) => void) => { + callback(destroyError); + }), + } + : undefined; + + return { + session, + } as unknown as Request; +}; + +describe("ExpressStore", () => { + let req: Request; + let sessionManager: ExpressStore; + + describe("constructor", () => { + it("should throw an error if session is not available on the request", () => { + req = mockRequest(null); + expect(() => new ExpressStore(req)).toThrow( + "Session not available on the request. Please ensure the 'express-session' middleware is configured and running before the Kinde middleware.", + ); + }); + + it("should not throw an error if session is available on the request", () => { + req = mockRequest({}); + expect(() => new ExpressStore(req)).not.toThrow(); + }); + }); + + describe("with a valid session", () => { + const keyPrefix = storageSettings.keyPrefix; + beforeEach(() => { + const initialSession = { + [`${keyPrefix}${StorageKeys.accessToken}0`]: "access-token", + [`${keyPrefix}${StorageKeys.idToken}0`]: "id-token", + }; + req = mockRequest(initialSession); + sessionManager = new ExpressStore(req); + }); + + it("should get an item from the session", async () => { + const accessToken = await sessionManager.getSessionItem( + StorageKeys.accessToken, + ); + expect(accessToken).toBe("access-token"); + }); + + it("should return null for a non-existent item", async () => { + const refreshToken = await sessionManager.getSessionItem( + StorageKeys.refreshToken, + ); + expect(refreshToken).toBeNull(); + }); + + it("should set an item in the session", async () => { + await sessionManager.setSessionItem( + StorageKeys.refreshToken, + "refresh-token", + ); + expect(req.session![`${keyPrefix}${StorageKeys.refreshToken}0`]).toBe( + "refresh-token", + ); + }); + + it("should remove an item from the session", async () => { + await sessionManager.removeSessionItem(StorageKeys.accessToken); + expect( + req.session![`${keyPrefix}${StorageKeys.accessToken}0`], + ).toBeUndefined(); + }); + + it("should destroy the session", async () => { + await sessionManager.destroySession(); + expect(req.session!.destroy).toHaveBeenCalled(); + }); + + it("should reject with an error if destroying the session fails", async () => { + const error = new Error("Failed to destroy Kinde session"); + req = mockRequest({}, error); + sessionManager = new ExpressStore(req); + await expect(sessionManager.destroySession()).rejects.toThrow(error); + }); + }); + + describe("splitting and reassembly logic", () => { + const longString = "a".repeat(5000); // longer than default maxLength (2000) + const keyPrefix = storageSettings.keyPrefix; + const maxLength = storageSettings.maxLength; + let req: Request; + let sessionManager: ExpressStore; + + beforeEach(() => { + req = mockRequest({}); + sessionManager = new ExpressStore(req); + }); + + it("should split and store a long string value across multiple session keys", async () => { + await sessionManager.setSessionItem(StorageKeys.state, longString); + expect(req.session![`${keyPrefix}state0`]).toBe( + longString.slice(0, maxLength), + ); + expect(req.session![`${keyPrefix}state1`]).toBe( + longString.slice(maxLength, maxLength * 2), + ); + expect(req.session![`${keyPrefix}state2`]).toBe( + longString.slice(maxLength * 2), + ); + expect(req.session![`${keyPrefix}state3`]).toBeUndefined(); + }); + + it("should reassemble a long string value from multiple session keys", async () => { + // Simulate split storage + req.session![`${keyPrefix}state0`] = longString.slice(0, maxLength); + req.session![`${keyPrefix}state1`] = longString.slice( + maxLength, + maxLength * 2, + ); + req.session![`${keyPrefix}state2`] = longString.slice(maxLength * 2); + const value = await sessionManager.getSessionItem(StorageKeys.state); + expect(value).toBe(longString); + }); + + it("should remove all split keys for a long string value", async () => { + req.session![`${keyPrefix}state0`] = "part1"; + req.session![`${keyPrefix}state1`] = "part2"; + req.session![`${keyPrefix}state2`] = "part3"; + await sessionManager.removeSessionItem(StorageKeys.state); + expect(req.session![`${keyPrefix}state0`]).toBeUndefined(); + expect(req.session![`${keyPrefix}state1`]).toBeUndefined(); + expect(req.session![`${keyPrefix}state2`]).toBeUndefined(); + }); + + it("should store and retrieve non-string values without splitting", async () => { + const obj = { foo: "bar" }; + await sessionManager.setSessionItem(StorageKeys.nonce, obj); + expect(req.session![`${keyPrefix}nonce0`]).toEqual(obj); + const value = await sessionManager.getSessionItem(StorageKeys.nonce); + expect(value).toEqual(obj); // Should return the original object + }); + }); +}); diff --git a/lib/sessionManager/stores/expressStore.ts b/lib/sessionManager/stores/expressStore.ts new file mode 100644 index 0000000..31d8a29 --- /dev/null +++ b/lib/sessionManager/stores/expressStore.ts @@ -0,0 +1,130 @@ +import type { Request } from "express"; +import { SessionBase, StorageKeys, type SessionManager } from "../types.js"; +import { storageSettings } from "../index.js"; +import { splitString } from "../../utils/splitString.js"; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Express { + interface Request { + session?: { + [key: string]: unknown; + destroy: (callback: (err?: Error | null) => void) => void; + }; + } + } +} + +/** + * Provides an Express session-based session manager. + * This class acts as a structured interface to the 'req.session' object, + * that is populated by the express-session middleware. + * @class ExpressStore + */ +export class ExpressStore + extends SessionBase + implements SessionManager +{ + /** + * Indicates this store uses async operations + */ + asyncStore = true; + + /** + * The Express req obj which holds the session's data + */ + private req: Request; + + constructor(req: Request) { + super(); + if (!req.session) { + throw new Error( + "Session not available on the request. Please ensure the 'express-session' middleware is configured and running before the Kinde middleware.", + ); + } + this.req = req; + } + + /** + * Gets a value from the Express session. + * @param {string} itemKey + * @returns {Promise} + */ + async getSessionItem(itemKey: V | StorageKeys): Promise { + // Reassemble split string values if present + const baseKey = `${storageSettings.keyPrefix}${String(itemKey)}`; + if (this.req.session![`${baseKey}0`] === undefined) { + return null; + } + + // if under settingConfig maxLength - return as-is + if (this.req.session![`${baseKey}1`] === undefined) { + return this.req.session![`${baseKey}0`]; + } + + // Multiple items exist, concatenate them as strings (for split strings) + let itemValue = ""; + let index = 0; + let key = `${baseKey}${index}`; + while (this.req.session![key] !== undefined) { + itemValue += this.req.session![key] as string; + index++; + key = `${baseKey}${index}`; + } + return itemValue; + } + + /** + * Sets a value in the Express session. + * @param {string} itemKey + * @param {unknown} itemValue + * @returns {Promise} + */ + async setSessionItem( + itemKey: V | StorageKeys, + itemValue: unknown, + ): Promise { + // Remove any existing split items first + await this.removeSessionItem(itemKey); + const baseKey = `${storageSettings.keyPrefix}${String(itemKey)}`; + if (typeof itemValue === "string") { + splitString(itemValue, storageSettings.maxLength).forEach( + (splitValue, index) => { + this.req.session![`${baseKey}${index}`] = splitValue; + }, + ); + return; + } + this.req.session![`${baseKey}0`] = itemValue; + } + + /** + * Removes a value from the Express session. + * @param {string} itemKey + * @returns {Promise} + */ + async removeSessionItem(itemKey: V | StorageKeys): Promise { + // Remove all items with the key prefix + const baseKey = `${storageSettings.keyPrefix}${String(itemKey)}`; + for (const key in this.req.session!) { + if (key.startsWith(baseKey)) { + delete this.req.session![key]; + } + } + } + + /** + * Clears the entire Express session. + * @returns {Promise} + */ + async destroySession(): Promise { + return new Promise((resolve, reject) => { + this.req.session!.destroy((err) => { + if (err) { + return reject(err); + } + resolve(); + }); + }); + } +} diff --git a/package.json b/package.json index e3b7069..0b2a314 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,8 @@ "vite": "^7.0.0", "vite-plugin-dts": "^4.0.3", "vitest": "^4.0.3", - "vitest-fetch-mock": "^0.4.1" + "vitest-fetch-mock": "^0.4.1", + "@types/express": "^4.17.0" }, "peerDependencies": { "expo-secure-store": ">=11.0.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e2bdb0c..4e88c6d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,6 +21,9 @@ importers: '@types/chrome': specifier: ^0.1.0 version: 0.1.22 + '@types/express': + specifier: ^4.17.0 + version: 4.17.23 '@types/node': specifier: ^24.0.0 version: 24.9.1 @@ -1471,18 +1474,30 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/chai@5.2.2': resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} '@types/chrome@0.1.22': resolution: {integrity: sha512-5uXbw/3V+Pdu9BaoTvudvutITxeIiC0CK5WhQr8lzEhAQ70lTpe5ebnoai9iQrqWjhIa3qynYhKV0NN0MKv4qA==} + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/express-serve-static-core@4.19.6': + resolution: {integrity: sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==} + + '@types/express@4.17.23': + resolution: {integrity: sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==} + '@types/filesystem@0.0.36': resolution: {integrity: sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==} @@ -1495,6 +1510,9 @@ packages: '@types/har-format@1.2.16': resolution: {integrity: sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==} + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/istanbul-lib-coverage@2.0.6': resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} @@ -1513,6 +1531,18 @@ packages: '@types/node@24.9.1': resolution: {integrity: sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==} + '@types/qs@6.14.0': + resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@2.2.0': + resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} @@ -6308,6 +6338,11 @@ snapshots: dependencies: '@babel/types': 7.28.4 + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 24.9.1 + '@types/chai@5.2.2': dependencies: '@types/deep-eql': 4.0.2 @@ -6317,10 +6352,28 @@ snapshots: '@types/filesystem': 0.0.36 '@types/har-format': 1.2.16 + '@types/connect@3.4.38': + dependencies: + '@types/node': 24.9.1 + '@types/deep-eql@4.0.2': {} '@types/estree@1.0.8': {} + '@types/express-serve-static-core@4.19.6': + dependencies: + '@types/node': 24.9.1 + '@types/qs': 6.14.0 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@4.17.23': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 4.19.6 + '@types/qs': 6.14.0 + '@types/serve-static': 2.2.0 + '@types/filesystem@0.0.36': dependencies: '@types/filewriter': 0.0.33 @@ -6333,6 +6386,8 @@ snapshots: '@types/har-format@1.2.16': {} + '@types/http-errors@2.0.5': {} + '@types/istanbul-lib-coverage@2.0.6': {} '@types/istanbul-lib-report@3.0.3': @@ -6353,6 +6408,19 @@ snapshots: dependencies: undici-types: 7.16.0 + '@types/qs@6.14.0': {} + + '@types/range-parser@1.2.7': {} + + '@types/send@1.2.1': + dependencies: + '@types/node': 24.9.1 + + '@types/serve-static@2.2.0': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 24.9.1 + '@types/stack-utils@2.0.3': {} '@types/yargs-parser@21.0.3': {}