From d1b5e49b56546dee183ffca46b469d7014e00fe4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 31 Jan 2026 14:27:18 +0000 Subject: [PATCH 1/4] Initial plan From c04a5bb11e78bbab07066046b35a4d4781831665 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 31 Jan 2026 14:30:51 +0000 Subject: [PATCH 2/4] Add storage provider interface with browser and in-memory implementations Co-authored-by: rickygarg <3947328+rickygarg@users.noreply.github.com> --- src/index.ts | 106 +++++++++++++++++++++++++++++++++++++++----- tests/index.test.ts | 8 ++-- 2 files changed, 99 insertions(+), 15 deletions(-) diff --git a/src/index.ts b/src/index.ts index dedc90e..ac7ddc6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,85 @@ import jwt_decode from "jwt-decode"; +export interface StorageProvider { + getItem(key: string): string | null | Promise; + setItem(key: string, value: string): void | Promise; + removeItem(key: string): void | Promise; +} + +export interface TokenNames { + token?: string; + refreshToken?: string; +} + +export class BrowserStorageProvider implements StorageProvider { + private tokenNames: Required; + + constructor(tokenNames?: TokenNames) { + this.tokenNames = { + token: tokenNames?.token || "token", + refreshToken: tokenNames?.refreshToken || "refreshToken", + }; + } + + getItem(key: string): string | null { + if (key === "token") { + return (typeof sessionStorage !== "undefined") ? (sessionStorage.getItem(this.tokenNames.token)) : null; + } + if (key === "refreshToken") { + return (typeof localStorage !== "undefined") ? (localStorage.getItem(this.tokenNames.refreshToken)) : null; + } + return null; + } + + setItem(key: string, value: string): void { + if (key === "token" && typeof sessionStorage !== "undefined") { + sessionStorage.setItem(this.tokenNames.token, value); + } + if (key === "refreshToken" && typeof localStorage !== "undefined") { + localStorage.setItem(this.tokenNames.refreshToken, value); + } + } + + removeItem(key: string): void { + if (key === "token" && typeof sessionStorage !== "undefined") { + sessionStorage.removeItem(this.tokenNames.token); + } + if (key === "refreshToken" && typeof localStorage !== "undefined") { + localStorage.removeItem(this.tokenNames.refreshToken); + } + } +} + +export class InMemoryStorageProvider implements StorageProvider { + private store: Map; + + constructor() { + this.store = new Map(); + } + + getItem(key: string): string | null { + return this.store.get(key) || null; + } + + setItem(key: string, value: string): void { + this.store.set(key, value); + } + + removeItem(key: string): void { + this.store.delete(key); + } + + clear(): void { + this.store.clear(); + } +} + interface OIDCClientOptions { refreshPath?: string; baseUrl?: string; headers?: Record; + storageProvider?: StorageProvider; + tokenNames?: TokenNames; } interface TokenResponse { @@ -17,6 +93,7 @@ export class OIDCClient { private refreshPath: string; private baseUrl: string | undefined; private BASE_HEADERS: Record; + private storageProvider: StorageProvider; constructor(options?: OIDCClientOptions) { this.refreshTokenLock = false; @@ -26,6 +103,11 @@ export class OIDCClient { "Content-Type": "application/json; charset=UTF-8", Accept: "application/json, text/javascript, */*; q=0.01", }; + const tokenNames = { + token: options?.tokenNames?.token || "token", + refreshToken: options?.tokenNames?.refreshToken || "refreshToken", + }; + this.storageProvider = options?.storageProvider || new BrowserStorageProvider(tokenNames); this.#refreshTokenPromise = null; } @@ -68,9 +150,11 @@ export class OIDCClient { } async getAccessToken(): Promise { - if (typeof localStorage === "undefined" || !localStorage.getItem("refreshToken")) + const refreshToken = await this.storageProvider.getItem("refreshToken"); + if (!refreshToken) { // Either we're in a non-browser environment, or session security is used return; + } try { for (let count = 0; this.refreshTokenLock && count < 15; count++) { @@ -79,15 +163,15 @@ export class OIDCClient { } if (this.#refreshTokenPromise) await this.#refreshTokenPromise; - else if (!this.verifyTokenValidity()) await this._refreshToken(); + else if (!await this.verifyTokenValidity()) await this._refreshToken(); } catch (err) { console.log(err); - sessionStorage.removeItem("token"); - localStorage.removeItem("refreshToken"); + await this.storageProvider.removeItem("token"); + await this.storageProvider.removeItem("refreshToken"); document.dispatchEvent(new CustomEvent("logged-out", { bubbles: true, composed: true })); } - return sessionStorage.getItem("token") || undefined; + return await this.storageProvider.getItem("token") || undefined; } async _wait(time = 1200): Promise { @@ -98,8 +182,8 @@ export class OIDCClient { }); } - verifyTokenValidity(): boolean { - const token = sessionStorage.getItem("token"); + async verifyTokenValidity(): Promise { + const token = await this.storageProvider.getItem("token"); if (!token) return false; try { const exp = jwt_decode<{ exp: number }>(token); @@ -110,8 +194,8 @@ export class OIDCClient { } async _refreshToken(): Promise { - const token = sessionStorage.getItem("token"); - const refreshToken = localStorage.getItem("refreshToken"); + const token = await this.storageProvider.getItem("token"); + const refreshToken = await this.storageProvider.getItem("refreshToken"); const headers: Record = { ...this.BASE_HEADERS }; if (!refreshToken) throw new Error("No refresh token"); @@ -136,8 +220,8 @@ export class OIDCClient { const refreshToken = data?.["refreshToken"] || response.headers.get("refreshToken"); if (!token && !refreshToken) throw new Error("Couldn't fetch `access-token` or `refresh-token`"); - if (token) sessionStorage.setItem("token", token); - if (refreshToken) localStorage.setItem("refreshToken", refreshToken); + if (token) await this.storageProvider.setItem("token", token); + if (refreshToken) await this.storageProvider.setItem("refreshToken", refreshToken); }) .catch((err) => { console.log("Failed to refresh tokens", err); diff --git a/tests/index.test.ts b/tests/index.test.ts index 82e55c0..5ef832a 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -54,16 +54,16 @@ test("Set base-url", () => { expect(baseUrl).toBe("https://fundwave.com"); }); -test("Verify invalid token", () => { +test("Verify invalid token", async () => { prepareToken("invalid"); - expect(OIDCClient.verifyTokenValidity()).toBe(false); + expect(await OIDCClient.verifyTokenValidity()).toBe(false); }); -test("Verify valid token", () => { +test("Verify valid token", async () => { prepareToken("valid"); - expect(OIDCClient.verifyTokenValidity()).toBe(true); + expect(await OIDCClient.verifyTokenValidity()).toBe(true); }); test("Prepare headers with valid token", async () => { From 7e50138cb31351977398284d2593795fe136eb32 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 31 Jan 2026 14:32:29 +0000 Subject: [PATCH 3/4] Add comprehensive tests for storage providers and update documentation Co-authored-by: rickygarg <3947328+rickygarg@users.noreply.github.com> --- README.md | 80 +++++++++++++++++++- tests/index.test.ts | 173 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 251 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a480c62..aab0c10 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,82 @@ oidcClient.setRefreshPath("refresh-token"); > Note: the `refreshPath` property defaults to **token/refresh** +### Storage Providers + +The library now supports flexible storage providers, allowing you to run multiple instances of OIDCClient in parallel without conflicts. + +#### Browser Storage Provider (Default) + +By default, the library uses `BrowserStorageProvider` which stores tokens in browser's localStorage and sessionStorage: + +```js +import { OIDCClient } from "@fundwave/oidc-client"; + +const oidcClient = new OIDCClient(); +// Access token is stored in sessionStorage with key "token" +// Refresh token is stored in localStorage with key "refreshToken" +``` + +#### Custom Token Names + +You can customize the token names to avoid conflicts between multiple instances: + +```js +import { OIDCClient } from "@fundwave/oidc-client"; + +const oidcClient1 = new OIDCClient({ + tokenNames: { token: "token", refreshToken: "refreshToken" } +}); + +const oidcClient2 = new OIDCClient({ + tokenNames: { token: "token2", refreshToken: "refreshToken2" } +}); + +// Now both instances can work in parallel using different storage keys +``` + +#### In-Memory Storage Provider + +For temporary storage that doesn't persist between page reloads: + +```js +import { OIDCClient, InMemoryStorageProvider } from "@fundwave/oidc-client"; + +const memoryStorage = new InMemoryStorageProvider(); +const oidcClient = new OIDCClient({ + storageProvider: memoryStorage +}); + +// Tokens are stored in memory and cleared on page reload +``` + +#### Custom Storage Provider + +You can implement your own storage provider (e.g., for MongoDB, IndexedDB, etc.): + +```js +import { OIDCClient, StorageProvider } from "@fundwave/oidc-client"; + +class MyCustomStorageProvider implements StorageProvider { + async getItem(key: string): Promise { + // Your custom implementation + } + + async setItem(key: string, value: string): Promise { + // Your custom implementation + } + + async removeItem(key: string): Promise { + // Your custom implementation + } +} + +const customStorage = new MyCustomStorageProvider(); +const oidcClient = new OIDCClient({ + storageProvider: customStorage +}); +``` + ### Usage Once the class has been instantiated, you can @@ -49,8 +125,8 @@ Once the class has been instantiated, you can - If your client app makes parallel calls to the same object of oidc-client, this library will still make only one active call to your OIDC server. This will reduce network calls and avoid exceeding any rate limits with your OIDC server. -- **Access Token** is maintained at browser's _session storage_ with the key being `token` +- **Multiple Instances**: You can now run multiple instances of OIDCClient in parallel by using different storage providers or custom token names. This solves the problem of one instance overriding another instance's tokens. -- **Refresh Token** is maintained at browser's _local storage_ with the key being `refreshToken` +- By default, **Access Token** is maintained at browser's _session storage_ with the key being `token`, and **Refresh Token** is maintained at browser's _local storage_ with the key being `refreshToken`. These can be customized using the `tokenNames` option. - The library will read tokens sent by your OIDC server from either the response **body** or **headers** diff --git a/tests/index.test.ts b/tests/index.test.ts index 5ef832a..14b0e21 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -169,3 +169,176 @@ test("Refresh Token URL (w/ malformed base-url | trailing '/')", async () => { await OIDCClient.prepareHeaders(); expect(suppliedRefreshPath).toEqual(expectedPefreshPath); }); + +describe("Storage Provider Tests", () => { + test("BrowserStorageProvider with default token names", async () => { + const { BrowserStorageProvider } = await import("../src"); + const provider = new BrowserStorageProvider(); + + provider.setItem("token", "test-token"); + provider.setItem("refreshToken", "test-refresh-token"); + + expect(provider.getItem("token")).toBe("test-token"); + expect(provider.getItem("refreshToken")).toBe("test-refresh-token"); + expect(sessionStorage.getItem("token")).toBe("test-token"); + expect(localStorage.getItem("refreshToken")).toBe("test-refresh-token"); + + provider.removeItem("token"); + provider.removeItem("refreshToken"); + + expect(provider.getItem("token")).toBeNull(); + expect(provider.getItem("refreshToken")).toBeNull(); + }); + + test("BrowserStorageProvider with custom token names", async () => { + const { BrowserStorageProvider } = await import("../src"); + const provider = new BrowserStorageProvider({ token: "customToken", refreshToken: "customRefreshToken" }); + + provider.setItem("token", "test-token"); + provider.setItem("refreshToken", "test-refresh-token"); + + expect(provider.getItem("token")).toBe("test-token"); + expect(provider.getItem("refreshToken")).toBe("test-refresh-token"); + expect(sessionStorage.getItem("customToken")).toBe("test-token"); + expect(localStorage.getItem("customRefreshToken")).toBe("test-refresh-token"); + + // Check that default token names are not used + expect(sessionStorage.getItem("token")).toBeNull(); + expect(localStorage.getItem("refreshToken")).toBeNull(); + + provider.removeItem("token"); + provider.removeItem("refreshToken"); + + expect(provider.getItem("token")).toBeNull(); + expect(provider.getItem("refreshToken")).toBeNull(); + }); + + test("InMemoryStorageProvider", async () => { + const { InMemoryStorageProvider } = await import("../src"); + const provider = new InMemoryStorageProvider(); + + provider.setItem("token", "test-token"); + provider.setItem("refreshToken", "test-refresh-token"); + + expect(provider.getItem("token")).toBe("test-token"); + expect(provider.getItem("refreshToken")).toBe("test-refresh-token"); + + // Ensure it doesn't touch browser storage + expect(sessionStorage.getItem("token")).toBeNull(); + expect(localStorage.getItem("refreshToken")).toBeNull(); + + provider.removeItem("token"); + expect(provider.getItem("token")).toBeNull(); + expect(provider.getItem("refreshToken")).toBe("test-refresh-token"); + + provider.clear(); + expect(provider.getItem("refreshToken")).toBeNull(); + }); + + test("OIDCClient with custom token names", async () => { + clearStorageMocks(); + + const client = new Client({ + baseUrl: providedBaseURL, + tokenNames: { token: "myToken", refreshToken: "myRefreshToken" } + }); + + const { token: validToken } = prepareToken("valid"); + + // Move tokens to custom names + sessionStorage.removeItem("token"); + localStorage.removeItem("refreshToken"); + sessionStorage.setItem("myToken", validToken); + localStorage.setItem("myRefreshToken", validToken); + + const headers = await client.prepareHeaders(); + expect(headers.Authorization).toBe(`Bearer ${validToken}`); + }); + + test("OIDCClient with InMemoryStorageProvider", async () => { + expect.assertions(1); + + const { InMemoryStorageProvider } = await import("../src"); + const provider = new InMemoryStorageProvider(); + + const client = new Client({ + baseUrl: providedBaseURL, + storageProvider: provider + }); + + const { token: validToken } = prepareToken("valid"); + + // Set tokens in the in-memory provider + provider.setItem("token", validToken); + provider.setItem("refreshToken", validToken); + + const headers = await client.prepareHeaders(); + expect(headers.Authorization).toBe(`Bearer ${validToken}`); + }); + + test("Multiple OIDCClient instances with different storage providers", async () => { + expect.assertions(2); + + clearStorageMocks(); + const { InMemoryStorageProvider } = await import("../src"); + + // Instance 1: BrowserStorageProvider with default token names + const client1 = new Client({ + baseUrl: providedBaseURL, + }); + + // Instance 2: InMemoryStorageProvider + const provider2 = new InMemoryStorageProvider(); + const client2 = new Client({ + baseUrl: providedBaseURL, + storageProvider: provider2 + }); + + const { token: token1 } = prepareToken("valid"); + const expiryTime2 = timeWithOffsetFromThreshold(5); + const token2 = jwt.sign({ exp: expiryTime2 }, "different-secret"); + + // Set different tokens for each instance + provider2.setItem("token", token2); + provider2.setItem("refreshToken", token2); + + const headers1 = await client1.prepareHeaders(); + const headers2 = await client2.prepareHeaders(); + + expect(headers1.Authorization).toBe(`Bearer ${token1}`); + expect(headers2.Authorization).toBe(`Bearer ${token2}`); + }); + + test("Multiple OIDCClient instances with different token names", async () => { + expect.assertions(2); + + clearStorageMocks(); + + // Instance 1: Default token names + const client1 = new Client({ + baseUrl: providedBaseURL, + }); + + // Instance 2: Custom token names + const client2 = new Client({ + baseUrl: providedBaseURL, + tokenNames: { token: "token2", refreshToken: "refreshToken2" } + }); + + const expiryTime = timeWithOffsetFromThreshold(5); + const token1 = jwt.sign({ exp: expiryTime }, "secret1"); + const token2 = jwt.sign({ exp: expiryTime }, "secret2"); + + // Set different tokens for each instance + sessionStorage.setItem("token", token1); + localStorage.setItem("refreshToken", token1); + sessionStorage.setItem("token2", token2); + localStorage.setItem("refreshToken2", token2); + + const headers1 = await client1.prepareHeaders(); + const headers2 = await client2.prepareHeaders(); + + expect(headers1.Authorization).toBe(`Bearer ${token1}`); + expect(headers2.Authorization).toBe(`Bearer ${token2}`); + }); +}); From d8fd57db290cf7f53d9fcdf669a779c9bb293f52 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 31 Jan 2026 14:34:03 +0000 Subject: [PATCH 4/4] Address code review feedback: fix nullish coalescing and improve test assertions Co-authored-by: rickygarg <3947328+rickygarg@users.noreply.github.com> --- src/index.ts | 2 +- tests/index.test.ts | 19 +++++++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/index.ts b/src/index.ts index ac7ddc6..952a774 100644 --- a/src/index.ts +++ b/src/index.ts @@ -58,7 +58,7 @@ export class InMemoryStorageProvider implements StorageProvider { } getItem(key: string): string | null { - return this.store.get(key) || null; + return this.store.get(key) ?? null; } setItem(key: string, value: string): void { diff --git a/tests/index.test.ts b/tests/index.test.ts index 14b0e21..ab02e69 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -256,7 +256,9 @@ describe("Storage Provider Tests", () => { }); test("OIDCClient with InMemoryStorageProvider", async () => { - expect.assertions(1); + expect.assertions(2); + + clearStorageMocks(); // Clear any tokens set by prepareToken const { InMemoryStorageProvider } = await import("../src"); const provider = new InMemoryStorageProvider(); @@ -266,18 +268,22 @@ describe("Storage Provider Tests", () => { storageProvider: provider }); - const { token: validToken } = prepareToken("valid"); + const expiryTime = timeWithOffsetFromThreshold(5); + const validToken = jwt.sign({ exp: expiryTime }, "secret"); - // Set tokens in the in-memory provider + // Set tokens in the in-memory provider only provider.setItem("token", validToken); provider.setItem("refreshToken", validToken); const headers = await client.prepareHeaders(); expect(headers.Authorization).toBe(`Bearer ${validToken}`); + + // Verify browser storage remains unaffected + expect(sessionStorage.getItem("token")).toBeNull(); }); test("Multiple OIDCClient instances with different storage providers", async () => { - expect.assertions(2); + expect.assertions(4); clearStorageMocks(); const { InMemoryStorageProvider } = await import("../src"); @@ -307,6 +313,11 @@ describe("Storage Provider Tests", () => { expect(headers1.Authorization).toBe(`Bearer ${token1}`); expect(headers2.Authorization).toBe(`Bearer ${token2}`); + + // Verify isolation: client1's tokens in browser storage + expect(sessionStorage.getItem("token")).toBe(token1); + // Verify client2's tokens NOT in browser storage (only in memory) + expect(provider2.getItem("token")).toBe(token2); }); test("Multiple OIDCClient instances with different token names", async () => {