diff --git a/lib/main.test.ts b/lib/main.test.ts index 73fddbc..3e78584 100644 --- a/lib/main.test.ts +++ b/lib/main.test.ts @@ -60,6 +60,7 @@ describe("index exports", () => { "LocalStorage", "storageSettings", "ExpoSecureStore", + "ExpressStore", // token utils "getActiveStorage", diff --git a/lib/main.ts b/lib/main.ts index cab8a55..f108f37 100644 --- a/lib/main.ts +++ b/lib/main.ts @@ -57,6 +57,7 @@ export { ChromeStore, LocalStorage, StorageKeys, + ExpressStore, } from "./sessionManager"; // This export provides an implementation of SessionManager diff --git a/lib/sessionManager/index.ts b/lib/sessionManager/index.ts index 1c96581..ad7521e 100644 --- a/lib/sessionManager/index.ts +++ b/lib/sessionManager/index.ts @@ -25,6 +25,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 } 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..0561e54 --- /dev/null +++ b/lib/sessionManager/stores/expressStore.ts @@ -0,0 +1,125 @@ +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 +{ + /** + * 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 8a8aac1..36b1b80 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,8 @@ "vite": "^6.0.0", "vite-plugin-dts": "^4.0.3", "vitest": "^3.0.0", - "vitest-fetch-mock": "^0.4.1" + "vitest-fetch-mock": "^0.4.1", + "@types/express": "^4.17.0" }, "peerDependencies": { "expo-secure-store": ">=11.0.0" @@ -65,4 +66,4 @@ "dependencies": { "@kinde/jwt-decoder": "^0.2.0" } -} +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b55f30..c08bada 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@kinde/jwt-decoder': specifier: ^0.2.0 version: 0.2.0 + '@types/express': + specifier: ^4.17.0 + version: 4.17.23 expo-secure-store: specifier: '>=11.0.0' version: 14.0.1(expo@52.0.46(@babel/core@7.27.3)(@babel/preset-env@7.26.9(@babel/core@7.27.3))(react-native@0.79.1(@babel/core@7.27.3)(react@19.1.0))(react@19.1.0)) @@ -1452,12 +1455,24 @@ packages: '@types/babel__traverse@7.20.7': resolution: {integrity: sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==} + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/chrome@0.0.326': resolution: {integrity: sha512-WS7jKf3ZRZFHOX7dATCZwqNJgdfiSF0qBRFxaO0LhIOvTNBrfkab26bsZwp6EBpYtqp8loMHJTnD6vDTLWPKYw==} + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/estree@1.0.7': resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==} + '@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==} @@ -1470,6 +1485,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==} @@ -1482,15 +1500,27 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/mime@1.3.5': + resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + '@types/node-forge@1.3.11': resolution: {integrity: sha512-FQx220y22OKNTqaByeBGqHWYz4cl94tpcxeFdvBo3wjG6XPBuZ0BNgNZRV5J5TFmmcsJ4IzsLkmGRiQbnYsBEQ==} - '@types/node@22.15.24': - resolution: {integrity: sha512-w9CZGm9RDjzTh/D+hFwlBJ3ziUaVw7oufKA3vOFSOZlzmW9AkZnfjPb+DLnrV6qtgL/LNmP0/2zBNCFHL3F0ng==} - '@types/node@22.15.29': resolution: {integrity: sha512-LNdjOkUDlU1RZb8e1kOIUpN1qQUlzGkEtbVNo53vbrwDg5om6oduhm4SiUaPW5ASTXhAiP0jInWG8Qx9fVlOeQ==} + '@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@0.17.5': + resolution: {integrity: sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==} + + '@types/serve-static@1.15.8': + resolution: {integrity: sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==} + '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} @@ -5848,14 +5878,14 @@ snapshots: dependencies: '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 22.15.24 + '@types/node': 22.15.29 jest-mock: 29.7.0 '@jest/fake-timers@29.7.0': dependencies: '@jest/types': 29.6.3 '@sinonjs/fake-timers': 10.3.0 - '@types/node': 22.15.24 + '@types/node': 22.15.29 jest-message-util: 29.7.0 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -5889,7 +5919,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 22.15.24 + '@types/node': 22.15.29 '@types/yargs': 17.0.33 chalk: 4.1.2 @@ -6267,13 +6297,36 @@ snapshots: dependencies: '@babel/types': 7.27.3 + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 22.15.29 + '@types/chrome@0.0.326': dependencies: '@types/filesystem': 0.0.36 '@types/har-format': 1.2.16 + '@types/connect@3.4.38': + dependencies: + '@types/node': 22.15.29 + '@types/estree@1.0.7': {} + '@types/express-serve-static-core@4.19.6': + dependencies: + '@types/node': 22.15.29 + '@types/qs': 6.14.0 + '@types/range-parser': 1.2.7 + '@types/send': 0.17.5 + + '@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': 1.15.8 + '@types/filesystem@0.0.36': dependencies: '@types/filewriter': 0.0.33 @@ -6282,10 +6335,12 @@ snapshots: '@types/graceful-fs@4.1.9': dependencies: - '@types/node': 22.15.24 + '@types/node': 22.15.29 '@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': @@ -6298,17 +6353,30 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/mime@1.3.5': {} + '@types/node-forge@1.3.11': dependencies: - '@types/node': 22.15.24 + '@types/node': 22.15.29 - '@types/node@22.15.24': + '@types/node@22.15.29': dependencies: undici-types: 6.21.0 - '@types/node@22.15.29': + '@types/qs@6.14.0': {} + + '@types/range-parser@1.2.7': {} + + '@types/send@0.17.5': dependencies: - undici-types: 6.21.0 + '@types/mime': 1.3.5 + '@types/node': 22.15.29 + + '@types/serve-static@1.15.8': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 22.15.29 + '@types/send': 0.17.5 '@types/stack-utils@2.0.3': {} @@ -6895,7 +6963,7 @@ snapshots: chrome-launcher@0.15.2: dependencies: - '@types/node': 22.15.24 + '@types/node': 22.15.29 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 1.4.2 @@ -6904,7 +6972,7 @@ snapshots: chromium-edge-launcher@0.2.0: dependencies: - '@types/node': 22.15.24 + '@types/node': 22.15.29 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 1.4.2 @@ -7824,7 +7892,7 @@ snapshots: '@jest/environment': 29.7.0 '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 22.15.24 + '@types/node': 22.15.29 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -7834,7 +7902,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@types/graceful-fs': 4.1.9 - '@types/node': 22.15.24 + '@types/node': 22.15.29 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -7861,7 +7929,7 @@ snapshots: jest-mock@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 22.15.24 + '@types/node': 22.15.29 jest-util: 29.7.0 jest-regex-util@29.6.3: {} @@ -7869,7 +7937,7 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 22.15.24 + '@types/node': 22.15.29 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -7886,7 +7954,7 @@ snapshots: jest-worker@29.7.0: dependencies: - '@types/node': 22.15.24 + '@types/node': 22.15.29 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1