diff --git a/lib/main.test.ts b/lib/main.test.ts index 6338656..247cb73 100644 --- a/lib/main.test.ts +++ b/lib/main.test.ts @@ -83,18 +83,29 @@ describe("index exports", () => { "hasInsecureStorage", "setInsecureStorage", "getClaim", + "getClaimSync", "getClaims", + "getClaimsSync", "getCurrentOrganization", + "getCurrentOrganizationSync", "getDecodedToken", + "getDecodedTokenSync", "getEntitlements", "getEntitlement", "getRawToken", + "getRawTokenSync", "getFlag", + "getFlagSync", "getPermission", + "getPermissionSync", "getPermissions", + "getPermissionsSync", "getRoles", + "getRolesSync", "getUserOrganizations", + "getUserOrganizationsSync", "getUserProfile", + "getUserProfileSync", "setActiveStorage", // config diff --git a/lib/main.ts b/lib/main.ts index 99381da..30501c1 100644 --- a/lib/main.ts +++ b/lib/main.ts @@ -28,18 +28,29 @@ export { export { getClaim, + getClaimSync, getClaims, + getClaimsSync, getCurrentOrganization, + getCurrentOrganizationSync, getRawToken, + getRawTokenSync, getDecodedToken, + getDecodedTokenSync, getFlag, + getFlagSync, getUserProfile, + getUserProfileSync, getPermission, + getPermissionSync, getEntitlement, getEntitlements, getPermissions, + getPermissionsSync, getUserOrganizations, + getUserOrganizationsSync, getRoles, + getRolesSync, isAuthenticated, isTokenExpired, refreshToken, diff --git a/lib/sessionManager/stores/chromeStore.ts b/lib/sessionManager/stores/chromeStore.ts index 3e6f50c..a20962d 100644 --- a/lib/sessionManager/stores/chromeStore.ts +++ b/lib/sessionManager/stores/chromeStore.ts @@ -22,6 +22,7 @@ export class ChromeStore extends SessionBase implements SessionManager { + asyncStore = true; /** * Clears all items from session store. * @returns {void} diff --git a/lib/sessionManager/stores/expoSecureStore.ts b/lib/sessionManager/stores/expoSecureStore.ts index 87376f7..0c195e0 100644 --- a/lib/sessionManager/stores/expoSecureStore.ts +++ b/lib/sessionManager/stores/expoSecureStore.ts @@ -19,6 +19,7 @@ async function waitForExpoSecureStore() { export class ExpoSecureStore< V extends string = StorageKeys, > extends SessionBase { + asyncStore = true; constructor() { super(); this.loadExpoStore(); diff --git a/lib/sessionManager/stores/localStorage.test.ts b/lib/sessionManager/stores/localStorage.test.ts index 285cec8..b581cf7 100644 --- a/lib/sessionManager/stores/localStorage.test.ts +++ b/lib/sessionManager/stores/localStorage.test.ts @@ -288,7 +288,7 @@ describe("LocalStorage subscription/listening mechanism", () => { await sessionManager.setSessionItem(StorageKeys.idToken, "mixedTest"); // Wait for setTimeout to fire and async listener to complete - await new Promise((resolve) => setTimeout(resolve, 10)); + await new Promise((resolve) => setTimeout(resolve, 20)); expect(syncCalled).toBe(true); expect(asyncCalled).toBe(true); diff --git a/lib/sessionManager/stores/localStorage.ts b/lib/sessionManager/stores/localStorage.ts index 7983dcb..3e9f942 100644 --- a/lib/sessionManager/stores/localStorage.ts +++ b/lib/sessionManager/stores/localStorage.ts @@ -10,6 +10,7 @@ export class LocalStorage extends SessionBase implements SessionManager { + asyncStore = false; constructor() { super(); if (storageSettings.useInsecureForRefreshToken) { @@ -23,9 +24,9 @@ export class LocalStorage * Clears all items from session store. * @returns {void} */ - async destroySession(): Promise { - await Promise.all( - Array.from(this.internalItems).map((key) => this.removeSessionItem(key)), + destroySession(): void { + Array.from(this.internalItems).forEach((key) => + this.removeSessionItem(key), ); this.notifyListeners(); @@ -37,12 +38,9 @@ export class LocalStorage * @param {unknown} itemValue * @returns {void} */ - async setSessionItem( - itemKey: V | StorageKeys, - itemValue: unknown, - ): Promise { + setSessionItem(itemKey: V | StorageKeys, itemValue: unknown): void { // clear items first - await this.removeSessionItem(itemKey); + this.removeSessionItem(itemKey); this.internalItems.add(itemKey); if (typeof itemValue === "string") { @@ -70,7 +68,7 @@ export class LocalStorage * @param {string} itemKey * @returns {unknown | null} */ - async getSessionItem(itemKey: V | StorageKeys): Promise { + getSessionItem(itemKey: V | StorageKeys): unknown | null { if ( localStorage.getItem(`${storageSettings.keyPrefix}${itemKey}0`) === null ) { @@ -94,7 +92,7 @@ export class LocalStorage * @param {V} itemKey * @returns {void} */ - async removeSessionItem(itemKey: V | StorageKeys): Promise { + removeSessionItem(itemKey: V | StorageKeys): void { // Remove all items with the key prefix let index = 0; while ( diff --git a/lib/sessionManager/stores/memory.ts b/lib/sessionManager/stores/memory.ts index 92dddba..b848cd9 100644 --- a/lib/sessionManager/stores/memory.ts +++ b/lib/sessionManager/stores/memory.ts @@ -10,13 +10,14 @@ export class MemoryStorage extends SessionBase implements SessionManager { + asyncStore = false; private memCache: Record = {}; /** * Clears all items from session store. * @returns {void} */ - async destroySession(): Promise { + destroySession(): void { this.memCache = {}; this.notifyListeners(); } @@ -27,12 +28,9 @@ export class MemoryStorage * @param {unknown} itemValue * @returns {void} */ - async setSessionItem( - itemKey: V | StorageKeys, - itemValue: unknown, - ): Promise { + setSessionItem(itemKey: V | StorageKeys, itemValue: unknown): void { // clear items first - await this.removeSessionItem(itemKey); + this.removeSessionItem(itemKey); if (typeof itemValue === "string") { splitString(itemValue, storageSettings.maxLength).forEach( @@ -55,7 +53,7 @@ export class MemoryStorage * @param {string} itemKey * @returns {unknown | null} */ - async getSessionItem(itemKey: V | StorageKeys): Promise { + getSessionItem(itemKey: V | StorageKeys): unknown | null { if ( this.memCache[`${storageSettings.keyPrefix}${String(itemKey)}0`] === undefined @@ -80,7 +78,7 @@ export class MemoryStorage * @param {string} itemKey * @returns {void} */ - async removeSessionItem(itemKey: V | StorageKeys): Promise { + removeSessionItem(itemKey: V | StorageKeys): void { // Remove all items with the key prefix for (const key in this.memCache) { if (key.startsWith(`${storageSettings.keyPrefix}${String(itemKey)}`)) { diff --git a/lib/sessionManager/types.ts b/lib/sessionManager/types.ts index e5610a8..aa6f573 100644 --- a/lib/sessionManager/types.ts +++ b/lib/sessionManager/types.ts @@ -5,7 +5,7 @@ import { RefreshTokenResult, RefreshType } from "../types"; * satisfiy in order to work with this SDK, please vist the example provided in the * README, to understand how this works. */ -type Awaitable = Promise; +type Awaitable = T | Promise; type StoreListener = () => void | Promise; @@ -44,6 +44,7 @@ export type StorageSettingsType = { export abstract class SessionBase implements SessionManager { + abstract asyncStore: boolean; private listeners: Set = new Set(); private notificationScheduled = false; @@ -89,7 +90,7 @@ export abstract class SessionBase }; } - async setItems(items: Partial>): Awaitable { + async setItems(items: Partial>): Promise { await Promise.all( (Object.entries(items) as [V | StorageKeys, unknown][]).map( ([key, value]) => { @@ -99,7 +100,7 @@ export abstract class SessionBase ); } - async getItems(...items: V[]): Awaitable>> { + async getItems(...items: V[]): Promise>> { const promises = items.map(async (item) => { const value = await this.getSessionItem(item); return [item, value] as const; @@ -108,7 +109,7 @@ export abstract class SessionBase return Object.fromEntries(entries) as Partial>; } - async removeItems(...items: V[]): Awaitable { + async removeItems(...items: V[]): Promise { await Promise.all( items.map((item) => { return this.removeSessionItem(item); @@ -118,6 +119,7 @@ export abstract class SessionBase } export interface SessionManager { + asyncStore: boolean; /** * * Gets the item for the provided key from the storage. diff --git a/lib/utils/activityTracking.test.ts b/lib/utils/activityTracking.test.ts index 22bb17c..f0ad38d 100644 --- a/lib/utils/activityTracking.test.ts +++ b/lib/utils/activityTracking.test.ts @@ -215,14 +215,14 @@ describe("Activity Tracking", () => { await expect( activeStorage.setSessionItem(StorageKeys.accessToken, "token"), - ).resolves.toBeUndefined(); - await expect( - activeStorage.getSessionItem(StorageKeys.accessToken), - ).resolves.toBe("token"); + ).toBeUndefined(); + await expect(activeStorage.getSessionItem(StorageKeys.accessToken)).toBe( + "token", + ); await expect( activeStorage.removeSessionItem(StorageKeys.accessToken), - ).resolves.toBeUndefined(); - await expect(activeStorage.destroySession()).resolves.toBeUndefined(); + ).toBeUndefined(); + await expect(activeStorage.destroySession()).toBeUndefined(); }); it("should properly bind methods and maintain context", async () => { diff --git a/lib/utils/exchangeAuthCode.test.ts b/lib/utils/exchangeAuthCode.test.ts index 523f769..e5ee9bb 100644 --- a/lib/utils/exchangeAuthCode.test.ts +++ b/lib/utils/exchangeAuthCode.test.ts @@ -576,4 +576,46 @@ describe("exchangeAuthCode", () => { "refresh", ); }); + + it("returns error when persisting tokens to secure storage fails", async () => { + const store = new MemoryStorage(); + setActiveStorage(store); + + await store.setItems({ + [StorageKeys.state]: "state", + [StorageKeys.codeVerifier]: "verifier", + }); + + const urlParams = new URLSearchParams(); + urlParams.append("code", "hello"); + urlParams.append("state", "state"); + urlParams.append("client_id", "test"); + + fetchMock.mockResponseOnce( + JSON.stringify({ + access_token: "access_token", + refresh_token: "refresh_token", + id_token: "id_token", + }), + ); + + const setItemsSpy = vi + .spyOn(store, "setItems") + .mockRejectedValue(new Error("Persist failed")); + + const result = await exchangeAuthCode({ + urlParams, + domain: "http://test.kinde.com", + clientId: "test", + redirectURL: "http://test.kinde.com", + }); + + expect(setItemsSpy).toHaveBeenCalled(); + expect(result).toStrictEqual({ + success: false, + error: expect.stringContaining( + "Failed to persist tokens: Error: Persist failed", + ), + }); + }); }); diff --git a/lib/utils/exchangeAuthCode.ts b/lib/utils/exchangeAuthCode.ts index 7332df4..d1dfb52 100644 --- a/lib/utils/exchangeAuthCode.ts +++ b/lib/utils/exchangeAuthCode.ts @@ -187,11 +187,19 @@ export const exchangeAuthCode = async ({ const secureStore = getActiveStorage(); if (secureStore) { - secureStore.setItems({ - [StorageKeys.accessToken]: data.access_token, - [StorageKeys.idToken]: data.id_token, - [StorageKeys.refreshToken]: data.refresh_token, - }); + try { + await secureStore.setItems({ + [StorageKeys.accessToken]: data.access_token, + [StorageKeys.idToken]: data.id_token, + [StorageKeys.refreshToken]: data.refresh_token, + }); + } catch (error) { + console.error("Failed to persist tokens to secure storage:", error); + return { + success: false, + error: `Failed to persist tokens: ${error}`, + }; + } } if (storageSettings.useInsecureForRefreshToken || !isCustomDomain(domain)) { diff --git a/lib/utils/token/getClaim.test.ts b/lib/utils/token/getClaim.test.ts index 463512a..c8a29c5 100644 --- a/lib/utils/token/getClaim.test.ts +++ b/lib/utils/token/getClaim.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, beforeEach } from "vitest"; -import { getClaim, setActiveStorage } from "."; +import { getClaim, getClaimSync, setActiveStorage } from "."; import { createMockAccessToken } from "./testUtils"; import { MemoryStorage, StorageKeys } from "../../main"; @@ -11,7 +11,7 @@ describe("getClaim", () => { }); it("when no token", async () => { - await storage.setSessionItem(StorageKeys.accessToken, null); + await storage.removeSessionItem(StorageKeys.accessToken); const value = await getClaim("test"); expect(value).toStrictEqual(null); }); @@ -28,3 +28,24 @@ describe("getClaim", () => { }); }); }); + +describe("getClaimSync", () => { + beforeEach(() => { + setActiveStorage(storage); + }); + + it("when no token", () => { + storage.removeSessionItem(StorageKeys.accessToken); + const value = getClaimSync("test"); + expect(value).toStrictEqual(null); + }); + + it("get claim string value", () => { + storage.setSessionItem( + StorageKeys.accessToken, + createMockAccessToken({ test: "org_123456" }), + ); + const value = getClaimSync("test"); + expect(value).toStrictEqual({ name: "test", value: "org_123456" }); + }); +}); diff --git a/lib/utils/token/getClaim.ts b/lib/utils/token/getClaim.ts index 28a6204..4242c2e 100644 --- a/lib/utils/token/getClaim.ts +++ b/lib/utils/token/getClaim.ts @@ -1,5 +1,18 @@ import { JWTDecoded } from "@kinde/jwt-decoder"; -import { getClaims } from "./getClaims"; +import { getClaims, getClaimsSync } from "./getClaims"; + +const _getClaimCore = ( + claims: T | null, + keyName: keyof T, +): { name: keyof T; value: V } | null => { + if (!claims) { + return null; + } + return { + name: keyName, + value: (claims as T)[keyName] as unknown as V, + }; +}; /** * @@ -15,11 +28,16 @@ export const getClaim = async ( value: V; } | null> => { const claims = await getClaims(tokenType); - if (!claims) { - return null; - } - return { - name: keyName, - value: claims[keyName] as V, - }; + return _getClaimCore(claims, keyName); +}; + +export const getClaimSync = ( + keyName: keyof T, + tokenType: "accessToken" | "idToken" = "accessToken", +): { + name: keyof T; + value: V; +} | null => { + const claims = getClaimsSync(tokenType); + return _getClaimCore(claims, keyName); }; diff --git a/lib/utils/token/getClaims.ts b/lib/utils/token/getClaims.ts index 59c8570..88cadd6 100644 --- a/lib/utils/token/getClaims.ts +++ b/lib/utils/token/getClaims.ts @@ -1,5 +1,5 @@ import { JWTDecoded } from "@kinde/jwt-decoder"; -import { getDecodedToken } from "./getDecodedToken"; +import { getDecodedToken, getDecodedTokenSync } from "./getDecodedToken"; /** * get all claims from the token @@ -11,3 +11,9 @@ export const getClaims = async ( ): Promise => { return getDecodedToken(tokenType); }; + +export const getClaimsSync = ( + tokenType: "accessToken" | "idToken" = "accessToken", +): T | null => { + return getDecodedTokenSync(tokenType); +}; diff --git a/lib/utils/token/getCurrentOrganization.test.ts b/lib/utils/token/getCurrentOrganization.test.ts index 2b5bd8c..3050246 100644 --- a/lib/utils/token/getCurrentOrganization.test.ts +++ b/lib/utils/token/getCurrentOrganization.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it, beforeEach } from "vitest"; import { MemoryStorage, StorageKeys } from "../../sessionManager"; -import { setActiveStorage, getCurrentOrganization } from "."; +import { + setActiveStorage, + getCurrentOrganization, + getCurrentOrganizationSync, +} from "."; import { createMockAccessToken } from "./testUtils"; const storage = new MemoryStorage(); @@ -10,9 +14,9 @@ describe("getCurrentOrganization", () => { setActiveStorage(storage); }); it("when no token", async () => { - await storage.setSessionItem(StorageKeys.idToken, null); + await storage.removeSessionItem(StorageKeys.idToken); + await storage.removeSessionItem(StorageKeys.accessToken); const idToken = await getCurrentOrganization(); - expect(idToken).toStrictEqual(null); }); @@ -26,3 +30,25 @@ describe("getCurrentOrganization", () => { expect(orgCode).toStrictEqual("org_123456"); }); }); + +describe("getCurrentOrganizationSync", () => { + beforeEach(() => { + setActiveStorage(storage); + }); + it("when no token", () => { + storage.removeSessionItem(StorageKeys.idToken); + storage.removeSessionItem(StorageKeys.accessToken); + const orgCode = getCurrentOrganizationSync(); + + expect(orgCode).toStrictEqual(null); + }); + + it("with access", () => { + storage.setSessionItem( + StorageKeys.accessToken, + createMockAccessToken({ org_code: "org_123456" }), + ); + const orgCode = getCurrentOrganizationSync(); + expect(orgCode).toStrictEqual("org_123456"); + }); +}); diff --git a/lib/utils/token/getCurrentOrganization.ts b/lib/utils/token/getCurrentOrganization.ts index c239dd9..5036bba 100644 --- a/lib/utils/token/getCurrentOrganization.ts +++ b/lib/utils/token/getCurrentOrganization.ts @@ -1,4 +1,8 @@ -import { getDecodedToken } from "./getDecodedToken"; +import { + getDecodedToken, + getDecodedTokenSync, + JWTDecoded, +} from "./getDecodedToken"; /** * @@ -7,10 +11,19 @@ import { getDecodedToken } from "./getDecodedToken"; **/ export const getCurrentOrganization = async (): Promise => { const decodedToken = await getDecodedToken(); + return _getCurrentOrganizationCore(decodedToken); +}; + +export const getCurrentOrganizationSync = (): string | null => { + const decodedToken = getDecodedTokenSync(); + return _getCurrentOrganizationCore(decodedToken); +}; +const _getCurrentOrganizationCore = ( + decodedToken: JWTDecoded | null, +): string | null => { if (!decodedToken) { return null; } - return decodedToken.org_code || decodedToken["x-hasura-org-code"]; }; diff --git a/lib/utils/token/getCurrentOrganizationHasura.test.ts b/lib/utils/token/getCurrentOrganizationHasura.test.ts index fc26b5d..399327a 100644 --- a/lib/utils/token/getCurrentOrganizationHasura.test.ts +++ b/lib/utils/token/getCurrentOrganizationHasura.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it, beforeEach } from "vitest"; import { MemoryStorage, StorageKeys } from "../../sessionManager"; -import { setActiveStorage, getCurrentOrganization } from "."; +import { + setActiveStorage, + getCurrentOrganization, + getCurrentOrganizationSync, +} from "."; import { createMockAccessToken } from "./testUtils"; const storage = new MemoryStorage(); @@ -10,7 +14,7 @@ describe("getCurrentOrganization", () => { setActiveStorage(storage); }); it("when no token", async () => { - await storage.setSessionItem(StorageKeys.idToken, null); + await storage.removeSessionItem(StorageKeys.idToken); const idToken = await getCurrentOrganization(); expect(idToken).toStrictEqual(null); @@ -29,3 +33,27 @@ describe("getCurrentOrganization", () => { expect(orgCode).toStrictEqual("org_123456"); }); }); + +describe("getCurrentOrganizationSync - Hasura", () => { + beforeEach(() => { + setActiveStorage(storage); + }); + + it("when no token", () => { + storage.removeSessionItem(StorageKeys.accessToken); + const orgCode = getCurrentOrganizationSync(); + expect(orgCode).toStrictEqual(null); + }); + + it("with access", () => { + storage.setSessionItem( + StorageKeys.accessToken, + createMockAccessToken({ + org_code: null, + ["x-hasura-org-code"]: "org_123456", + }), + ); + const orgCode = getCurrentOrganizationSync(); + expect(orgCode).toStrictEqual("org_123456"); + }); +}); diff --git a/lib/utils/token/getDecodedToken.test.ts b/lib/utils/token/getDecodedToken.test.ts index 8fac5dc..66d0d8f 100644 --- a/lib/utils/token/getDecodedToken.test.ts +++ b/lib/utils/token/getDecodedToken.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, beforeEach } from "vitest"; -import { getDecodedToken } from "./getDecodedToken"; +import { getDecodedToken, getDecodedTokenSync } from "./getDecodedToken"; import { MemoryStorage, StorageKeys } from "../../sessionManager"; -import { setActiveStorage } from "."; +import { clearActiveStorage, setActiveStorage } from "."; import { createMockAccessToken } from "./testUtils"; describe("getDecodedToken", () => { @@ -49,3 +49,27 @@ describe("getDecodedToken accessToken", () => { expect(accessToken.org_code).toBe("org_123456789"); }); }); + +describe("getDecodedTokenSync", () => { + it("return null when no active storage is defined", () => { + clearActiveStorage(); + expect(getDecodedTokenSync("idToken")).toBe(null); + }); + + it("returns token when set on sync store", () => { + const storage = new MemoryStorage(); + setActiveStorage(storage); + storage.setSessionItem(StorageKeys.accessToken, createMockAccessToken()); + const t = getDecodedTokenSync("accessToken"); + expect(t?.org_code).toBe("org_123456789"); + }); + + it("using an async storage in sync mode throws an error", () => { + const storage = new MemoryStorage(); + storage.asyncStore = true; + setActiveStorage(storage); + expect(() => getDecodedTokenSync("accessToken")).toThrow( + "Active storage is async-only. Use the async helpers.", + ); + }); +}); diff --git a/lib/utils/token/getDecodedToken.ts b/lib/utils/token/getDecodedToken.ts index 5a91b2d..0232521 100644 --- a/lib/utils/token/getDecodedToken.ts +++ b/lib/utils/token/getDecodedToken.ts @@ -42,30 +42,50 @@ type JWTExtraHasura = { roles: never; }; -type JWTDecoded = JWTBase & (JWTExtra | JWTExtraHasura); +export type JWTDecoded = JWTBase & (JWTExtra | JWTExtraHasura); + +const _decodeTokenCore = ( + token: string | null, +): (T & JWTDecoded) | null => { + if (!token) { + return null; + } + + const decodedToken = jwtDecoder(token); + if (!decodedToken) { + console.warn("No decoded token found"); + } + return decodedToken; +}; export const getDecodedToken = async ( tokenType: "accessToken" | "idToken" = StorageKeys.accessToken, ): Promise<(T & JWTDecoded) | null> => { const activeStorage = getActiveStorage(); - if (!activeStorage) { return null; } const token = (await activeStorage.getSessionItem( tokenType === "accessToken" ? StorageKeys.accessToken : StorageKeys.idToken, - )) as string; + )) as string | null; - if (!token) { + return _decodeTokenCore(token); +}; + +export const getDecodedTokenSync = ( + tokenType: "accessToken" | "idToken" = StorageKeys.accessToken, +): (T & JWTDecoded) | null => { + const activeStorage = getActiveStorage(); + if (!activeStorage) { return null; } - - const decodedToken = jwtDecoder(token); - - if (!decodedToken) { - console.warn("No decoded token found"); + if (activeStorage.asyncStore) { + throw new Error("Active storage is async-only. Use the async helpers."); } + const token = activeStorage.getSessionItem( + tokenType === "accessToken" ? StorageKeys.accessToken : StorageKeys.idToken, + ) as string | null; - return decodedToken; + return _decodeTokenCore(token); }; diff --git a/lib/utils/token/getEntitlements.test.ts b/lib/utils/token/getEntitlements.test.ts index 6ef3ee3..366860a 100644 --- a/lib/utils/token/getEntitlements.test.ts +++ b/lib/utils/token/getEntitlements.test.ts @@ -30,6 +30,22 @@ const mockEntitlementsAPIResponse = { }, }; +const mockEntitlementsAPINoPlansAndEntitlementsResponse = { + data: { + org_code: "org_0195ac80a14e", + }, + metadata: { + has_more: false, + next_page_starting_after: "entitlement_0195ac80a14e8d71f42b98e75d3c61ad", + }, +}; + +const expectedResponseNoPlansAndEntitlements = { + orgCode: "org_0195ac80a14e", + plans: [], + entitlements: [], +}; + const expectedResponse = { orgCode: "org_0195ac80a14e", plans: [ @@ -74,6 +90,14 @@ describe("getEntitlements", () => { expect(result).toEqual(expectedResponse); }); + it("returns empty arrays when API response has no plans or entitlements", async () => { + fetchMock.mockResponseOnce( + JSON.stringify(mockEntitlementsAPINoPlansAndEntitlementsResponse), + ); + const result = await getEntitlements(); + expect(result).toEqual(expectedResponseNoPlansAndEntitlements); + }); + it("throws if no domain (iss claim)", async () => { vi.doMock("../src/utils/getClaim", () => ({ getClaim: vi.fn().mockReturnValue(undefined), diff --git a/lib/utils/token/getFlag.test.ts b/lib/utils/token/getFlag.test.ts index 9cfa2bc..1c81739 100644 --- a/lib/utils/token/getFlag.test.ts +++ b/lib/utils/token/getFlag.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, beforeEach, vi } from "vitest"; import { MemoryStorage, StorageKeys } from "../../sessionManager"; -import { setActiveStorage, getFlag } from "."; +import { setActiveStorage, getFlag, getFlagSync } from "."; import { createMockAccessToken } from "./testUtils"; import * as callAccountApi from "./accountApi/callAccountApi"; @@ -18,7 +18,7 @@ describe("getFlag", () => { }); it("when no token", async () => { - await storage.setSessionItem(StorageKeys.idToken, null); + await storage.removeSessionItem(StorageKeys.idToken); const flagValue = await getFlag("test"); expect(flagValue).toStrictEqual(null); }); @@ -220,3 +220,47 @@ describe("getFlag", () => { }); }); }); + +describe("getFlagSync", () => { + beforeEach(() => { + setActiveStorage(storage); + vi.clearAllMocks(); + }); + + it("when no token", () => { + storage.removeSessionItem(StorageKeys.idToken); + storage.removeSessionItem(StorageKeys.accessToken); + const flagValue = getFlagSync("test"); + expect(flagValue).toStrictEqual(null); + }); + + it("when no flags", () => { + storage.setSessionItem( + StorageKeys.accessToken, + createMockAccessToken({ + feature_flags: null, + }), + ); + const flagValue = getFlagSync("test"); + expect(flagValue).toStrictEqual(null); + }); + + it("boolean true", () => { + storage.setSessionItem( + StorageKeys.accessToken, + createMockAccessToken({ + feature_flags: { + test: { v: true, t: "b" }, + }, + }), + ); + const flagValue = getFlagSync("test"); + expect(flagValue).toStrictEqual(true); + }); + + it("throws on forceApi", () => { + expect(() => getFlagSync("x", { forceApi: true })).toThrow( + "forceApi cannot be used in sync mode", + ); + }); +}); diff --git a/lib/utils/token/getFlag.ts b/lib/utils/token/getFlag.ts index 0085734..43fcf98 100644 --- a/lib/utils/token/getFlag.ts +++ b/lib/utils/token/getFlag.ts @@ -1,10 +1,18 @@ import { getDecodedToken } from "."; +import { getDecodedTokenSync } from "./getDecodedToken"; import { type AccountFeatureFlagsResult, type GetFeatureFlagsOptions, } from "../../types"; import { callAccountApiPaginated } from "./accountApi/callAccountApi"; +type FlagValue = { v: unknown; t: string }; +type FlagsContainer = Record; +type TokenWithFlags = { + feature_flags?: FlagsContainer; + "x-hasura-feature-flags"?: FlagsContainer; +} | null; + /** * * @param keyName key to get from the token @@ -24,17 +32,32 @@ export const getFlag = async ( } const claims = await getDecodedToken(); + return _getFlagCore(claims, name); +}; + +export const getFlagSync = ( + name: string, + options?: GetFeatureFlagsOptions, +): T | null => { + if (options?.forceApi) { + throw new Error("forceApi cannot be used in sync mode"); + } + const claims = getDecodedTokenSync(); + return _getFlagCore(claims, name); +}; + +const _getFlagCore = ( + claims: TokenWithFlags, + name: string, +): T | null => { if (!claims) { return null; } - const flags = claims.feature_flags || claims["x-hasura-feature-flags"]; - if (!flags) { return null; } - const value = flags[name]; return (value?.v as T) ?? null; }; diff --git a/lib/utils/token/getFlagHasura.test.ts b/lib/utils/token/getFlagHasura.test.ts index 92f35ec..3de84ed 100644 --- a/lib/utils/token/getFlagHasura.test.ts +++ b/lib/utils/token/getFlagHasura.test.ts @@ -11,7 +11,7 @@ describe("getFlag - Hasura", () => { }); it("when no token", async () => { - await storage.setSessionItem(StorageKeys.accessToken, null); + await storage.removeSessionItem(StorageKeys.accessToken); const idToken = await getFlag("test"); expect(idToken).toStrictEqual(null); diff --git a/lib/utils/token/getFlags.test.ts b/lib/utils/token/getFlags.test.ts index 64e72b1..44ce555 100644 --- a/lib/utils/token/getFlags.test.ts +++ b/lib/utils/token/getFlags.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, beforeEach, vi } from "vitest"; import { MemoryStorage, StorageKeys } from "../../sessionManager"; -import { setActiveStorage, getFlags } from "."; +import { setActiveStorage, getFlags, getFlagsSync } from "."; import { createMockAccessToken } from "./testUtils"; import * as callAccountApi from "./accountApi/callAccountApi"; @@ -18,7 +18,7 @@ describe("getFlags", () => { }); it("when no token", async () => { - await storage.setSessionItem(StorageKeys.accessToken, null); + await storage.removeSessionItem(StorageKeys.accessToken); const flags = await getFlags(); expect(flags).toStrictEqual(null); }); @@ -337,3 +337,36 @@ describe("getFlags", () => { }); }); }); + +describe("getFlagsSync", () => { + beforeEach(() => { + setActiveStorage(storage); + }); + + it("when no token", () => { + storage.removeSessionItem(StorageKeys.accessToken); + const flags = getFlagsSync(); + expect(flags).toStrictEqual(null); + }); + + it("single boolean flag", () => { + storage.setSessionItem( + StorageKeys.accessToken, + createMockAccessToken({ + feature_flags: { + darkMode: { v: true, t: "boolean" }, + }, + }), + ); + const flags = getFlagsSync(); + expect(flags).toStrictEqual([ + { key: "darkMode", value: true, type: "boolean" }, + ]); + }); + + it("throws on forceApi", () => { + expect(() => getFlagsSync({ forceApi: true })).toThrow( + "forceApi cannot be used in sync mode", + ); + }); +}); diff --git a/lib/utils/token/getFlags.ts b/lib/utils/token/getFlags.ts index 268bab2..283bb12 100644 --- a/lib/utils/token/getFlags.ts +++ b/lib/utils/token/getFlags.ts @@ -1,4 +1,5 @@ import { getDecodedToken } from "."; +import { getDecodedTokenSync, JWTDecoded } from "./getDecodedToken"; import { type GetFeatureFlagsOptions, type AccountFeatureFlagsResult, @@ -29,17 +30,30 @@ export const getFlags = async ( } const claims = await getDecodedToken(); + return _getFlagsCore(claims); +}; + +export const getFlagsSync = ( + options?: GetFeatureFlagsOptions, +): TokenFeatureFlag[] | null => { + if (options?.forceApi) { + throw new Error("forceApi cannot be used in sync mode"); + } + const claims = getDecodedTokenSync(); + return _getFlagsCore(claims); +}; + +const _getFlagsCore = ( + claims: JWTDecoded | null, +): TokenFeatureFlag[] | null => { if (!claims) { return null; } - const flags = claims.feature_flags || claims["x-hasura-feature-flags"]; - if (!flags) { return null; } - return Object.entries(flags).map(([key, value]) => ({ key, value: value.v, diff --git a/lib/utils/token/getPermission.test.ts b/lib/utils/token/getPermission.test.ts index aa24c36..99e162b 100644 --- a/lib/utils/token/getPermission.test.ts +++ b/lib/utils/token/getPermission.test.ts @@ -1,10 +1,47 @@ -import { describe, expect, it, beforeEach } from "vitest"; +import { describe, expect, it, beforeEach, vi } from "vitest"; import { MemoryStorage, StorageKeys } from "../../sessionManager"; -import { setActiveStorage, getPermission } from "."; +import { setActiveStorage, getPermission, getPermissionSync } from "."; import { createMockAccessToken } from "./testUtils"; +import * as callAccountApi from "./accountApi/callAccountApi"; const storage = new MemoryStorage(); +describe("getPermissionSync", () => { + beforeEach(() => { + storage.destroySession(); + setActiveStorage(storage); + }); + + it("returns false when no token", () => { + storage.removeSessionItem(StorageKeys.accessToken); + const res = getPermissionSync("perm1"); + expect(res).toStrictEqual({ + permissionKey: "perm1", + orgCode: null, + isGranted: false, + }); + }); + + it("reads from token", () => { + storage.setSessionItem( + StorageKeys.accessToken, + createMockAccessToken({ org_code: "org_1", permissions: ["perm1"] }), + ); + const res = getPermissionSync("perm1"); + expect(res).toStrictEqual({ + permissionKey: "perm1", + orgCode: "org_1", + isGranted: true, + }); + }); + + it("throws on forceApi", () => { + expect(() => getPermissionSync("perm1", { forceApi: true })).toThrow( + "forceApi cannot be used in sync mode", + ); + }); +}); + enum PermissionEnum { canEdit = "canEdit", } @@ -14,7 +51,9 @@ describe("getPermission", () => { setActiveStorage(storage); }); it("when no token", async () => { - await storage.setSessionItem(StorageKeys.idToken, null); + storage.removeSessionItem(StorageKeys.idToken); + storage.removeSessionItem(StorageKeys.accessToken); + const idToken = await getPermission("test"); expect(idToken).toStrictEqual({ @@ -25,7 +64,8 @@ describe("getPermission", () => { }); it("when no token with enum", async () => { - await storage.setSessionItem(StorageKeys.idToken, null); + storage.removeSessionItem(StorageKeys.idToken); + storage.removeSessionItem(StorageKeys.accessToken); const idToken = await getPermission(PermissionEnum.canEdit); expect(idToken).toStrictEqual({ @@ -80,3 +120,34 @@ describe("getPermission", () => { }); }); }); + +vi.mock("./accountApi/callAccountApi", () => ({ + callAccountApi: vi.fn(), +})); + +describe("getPermission - forceApi", () => { + beforeEach(() => { + vi.clearAllMocks(); + setActiveStorage(storage); + }); + + it("encodes permission key when calling account API", async () => { + vi.mocked(callAccountApi.callAccountApi).mockResolvedValue({ + permissionKey: "view reports/advanced", + orgCode: "org_api", + isGranted: true, + }); + + const key = "view reports/advanced"; + const res = await getPermission(key, { forceApi: true }); + + expect(callAccountApi.callAccountApi).toHaveBeenCalledWith( + "account_api/v1/permission/view%20reports%2Fadvanced", + ); + expect(res).toStrictEqual({ + permissionKey: key, + orgCode: "org_api", + isGranted: true, + }); + }); +}); diff --git a/lib/utils/token/getPermission.ts b/lib/utils/token/getPermission.ts index 573799a..5a2c5ec 100644 --- a/lib/utils/token/getPermission.ts +++ b/lib/utils/token/getPermission.ts @@ -1,4 +1,5 @@ import { getDecodedToken } from "."; +import { getDecodedTokenSync, JWTDecoded } from "./getDecodedToken"; import { GetPermissionOptions } from "../../types"; import { callAccountApi } from "./accountApi/callAccountApi"; @@ -8,6 +9,26 @@ export type PermissionAccess = { isGranted: boolean; }; +const _getPermissionCore = ( + token: JWTDecoded | null, + permissionKey: string, +): PermissionAccess => { + if (!token) { + return { + permissionKey, + orgCode: null, + isGranted: false, + }; + } + const permissions = token.permissions || token["x-hasura-permissions"] || []; + const orgCode = token.org_code || token["x-hasura-org-code"] || null; + return { + permissionKey, + orgCode, + isGranted: !!permissions.includes(permissionKey), + }; +}; + /** * * @param permissionKey gets the value of a permission @@ -24,19 +45,17 @@ export const getPermission = async ( } const token = await getDecodedToken(); + return _getPermissionCore(token, permissionKey as string); +}; - if (!token) { - return { - permissionKey: permissionKey as string, - orgCode: null, - isGranted: false, - }; +export const getPermissionSync = ( + permissionKey: T, + options?: GetPermissionOptions, +): PermissionAccess => { + if (options?.forceApi) { + throw new Error("forceApi cannot be used in sync mode"); } - const permissions = token.permissions || []; - return { - permissionKey: permissionKey as string, - orgCode: token.org_code, - isGranted: !!permissions.includes(permissionKey as string), - }; + const token = getDecodedTokenSync(); + return _getPermissionCore(token, permissionKey as string); }; diff --git a/lib/utils/token/getPermissions.test.ts b/lib/utils/token/getPermissions.test.ts index 7124f37..71884df 100644 --- a/lib/utils/token/getPermissions.test.ts +++ b/lib/utils/token/getPermissions.test.ts @@ -1,8 +1,7 @@ -import { vi, describe, expect, it, beforeEach, afterEach } from "vitest"; +import { describe, expect, it, beforeEach, vi, afterEach } from "vitest"; import { MemoryStorage, StorageKeys } from "../../sessionManager"; -import { setActiveStorage } from "."; +import { setActiveStorage, getPermissions, getPermissionsSync } from "."; import { createMockAccessToken } from "./testUtils"; -import { getPermissions } from "."; import createFetchMock from "vitest-fetch-mock"; enum PermissionEnum { @@ -23,7 +22,7 @@ describe("getPermissions", () => { }); it("when no token", async () => { - await storage.setSessionItem(StorageKeys.idToken, null); + await storage.removeSessionItem(StorageKeys.idToken); const idToken = await getPermissions(); expect(idToken).toStrictEqual({ @@ -161,3 +160,32 @@ describe("getPermissions", () => { }); }); }); + +describe("getPermissionsSync", () => { + beforeEach(() => { + setActiveStorage(storage); + }); + + it("when no token returns empty", () => { + storage.removeSessionItem(StorageKeys.accessToken); + expect(getPermissionsSync()).toStrictEqual({ + orgCode: null, + permissions: [], + }); + }); + + it("reads from token", () => { + storage.setSessionItem( + StorageKeys.accessToken, + createMockAccessToken({ org_code: "org_1", permissions: ["p1", "p2"] }), + ); + const res = getPermissionsSync(); + expect(res).toStrictEqual({ orgCode: "org_1", permissions: ["p1", "p2"] }); + }); + + it("throws on forceApi", () => { + expect(() => getPermissionsSync({ forceApi: true })).toThrow( + "forceApi cannot be used in sync mode", + ); + }); +}); diff --git a/lib/utils/token/getPermissions.ts b/lib/utils/token/getPermissions.ts index cb38637..7b921fc 100644 --- a/lib/utils/token/getPermissions.ts +++ b/lib/utils/token/getPermissions.ts @@ -1,4 +1,5 @@ import { getDecodedToken } from "."; +import { getDecodedTokenSync, JWTDecoded } from "./getDecodedToken"; import { BaseAccountResponse, GetPermissionsOptions } from "../../types"; import { callAccountApiPaginated } from "./accountApi/callAccountApi"; @@ -13,6 +14,24 @@ export type Permissions = { orgCode: string | null; permissions: T[]; }; + +const _getPermissionsCore = ( + token: JWTDecoded | null, +): Permissions => { + if (!token) { + return { + orgCode: null, + permissions: [], + }; + } + const permissions = token.permissions || token["x-hasura-permissions"] || []; + const orgCode = token.org_code || token["x-hasura-org-code"]; + + return { + orgCode, + permissions: permissions as T[], + }; +}; /** * Get all permissions * @returns { Promise } @@ -33,18 +52,16 @@ export const getPermissions = async ( } const token = await getDecodedToken(); + return _getPermissionsCore(token); +}; - if (!token) { - return { - orgCode: null, - permissions: [], - }; +export const getPermissionsSync = ( + options?: GetPermissionsOptions, +): Permissions => { + if (options?.forceApi) { + throw new Error("forceApi cannot be used in sync mode"); } - const permissions = token.permissions || token["x-hasura-permissions"] || []; - const orgCode = token.org_code || token["x-hasura-org-code"]; - return { - orgCode, - permissions: permissions as T[], - }; + const token = getDecodedTokenSync(); + return _getPermissionsCore(token); }; diff --git a/lib/utils/token/getPermissionsHasura.test.ts b/lib/utils/token/getPermissionsHasura.test.ts index 38f2e0a..c543e6f 100644 --- a/lib/utils/token/getPermissionsHasura.test.ts +++ b/lib/utils/token/getPermissionsHasura.test.ts @@ -16,7 +16,7 @@ describe("getPermissions - Hasura", () => { }); it("when no token", async () => { - await storage.setSessionItem(StorageKeys.accessToken, null); + storage.removeSessionItem(StorageKeys.accessToken); const idToken = await getPermissions(); expect(idToken).toStrictEqual({ diff --git a/lib/utils/token/getRawToken.test.ts b/lib/utils/token/getRawToken.test.ts index ca52b00..c5dcec3 100644 --- a/lib/utils/token/getRawToken.test.ts +++ b/lib/utils/token/getRawToken.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, beforeEach } from "vitest"; -import { getRawToken } from "./getRawToken"; +import { getRawToken, getRawTokenSync } from "./getRawToken"; import { MemoryStorage, StorageKeys } from "../../sessionManager"; -import { setActiveStorage } from "."; +import { clearActiveStorage, setActiveStorage } from "."; import { createMockAccessToken } from "./testUtils"; describe("getRawToken", () => { @@ -56,3 +56,33 @@ describe("getRawToken accessToken", () => { expect(accessToken).toBe(mockedToken); }); }); + +describe("getRawTokenSync", () => { + it("returns null when no active storage is defined", () => { + clearActiveStorage(); + expect(getRawTokenSync("idToken")).toBe(null); + }); + + it("returns null when token is not set on sync store", () => { + const storage = new MemoryStorage(); + setActiveStorage(storage); + expect(getRawTokenSync("idToken")).toBe(null); + }); + + it("returns token when set on sync store", () => { + const storage = new MemoryStorage(); + setActiveStorage(storage); + const mockedToken = createMockAccessToken({ unique: "sync" }); + storage.setSessionItem(StorageKeys.accessToken, mockedToken); + expect(getRawTokenSync("accessToken")).toBe(mockedToken); + }); + + it("using an async storage in sync mode throws an error", () => { + const storage = new MemoryStorage(); + storage.asyncStore = true; + setActiveStorage(storage); + expect(() => getRawTokenSync()).toThrow( + "Active storage is async-only. Use the async helpers.", + ); + }); +}); diff --git a/lib/utils/token/getRawToken.ts b/lib/utils/token/getRawToken.ts index 581c097..ba38cea 100644 --- a/lib/utils/token/getRawToken.ts +++ b/lib/utils/token/getRawToken.ts @@ -20,3 +20,27 @@ export const getRawToken = async ( return token; }; + +export const getRawTokenSync = ( + tokenType: "accessToken" | "idToken" = StorageKeys.accessToken, +): string | null => { + const activeStorage = getActiveStorage(); + + if (!activeStorage) { + return null; + } + + if (activeStorage.asyncStore) { + throw new Error("Active storage is async-only. Use the async helpers."); + } + + const token = activeStorage.getSessionItem( + tokenType === "accessToken" ? StorageKeys.accessToken : StorageKeys.idToken, + ) as string | null; + + if (!token) { + return null; + } + + return token; +}; diff --git a/lib/utils/token/getRoles.test.ts b/lib/utils/token/getRoles.test.ts index b9d78e7..d5a87a3 100644 --- a/lib/utils/token/getRoles.test.ts +++ b/lib/utils/token/getRoles.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, beforeEach, vi, afterEach } from "vitest"; import { MemoryStorage, StorageKeys } from "../../sessionManager"; -import { setActiveStorage } from "."; +import { getRolesSync, setActiveStorage } from "."; import { createMockAccessToken } from "./testUtils"; import { getRoles } from "."; import createFetchMock from "vitest-fetch-mock"; @@ -360,3 +360,32 @@ describe("getRoles", () => { getDecodedTokenSpy.mockRestore(); }); }); + +describe("getRolesSync", () => { + beforeEach(() => { + setActiveStorage(storage); + }); + + it("returns [] when no token", () => { + storage.removeSessionItem(StorageKeys.accessToken); + expect(() => getRolesSync()).to.throw("Authentication token not found."); + }); + + it("returns roles from token", () => { + storage.setSessionItem( + StorageKeys.accessToken, + createMockAccessToken({ + roles: [{ id: "1", key: "admin", name: "Admin" }], + }), + ); + const roles = getRolesSync(); + expect(roles).toStrictEqual([{ id: "1", key: "admin", name: "Admin" }]); + }); + + it("can't forceApi in sync request", () => { + storage.removeSessionItem(StorageKeys.accessToken); + expect(() => getRolesSync({ forceApi: true })).toThrow( + "forceApi cannot be used in sync mode", + ); + }); +}); diff --git a/lib/utils/token/getRoles.ts b/lib/utils/token/getRoles.ts index 91e8b3e..695582e 100644 --- a/lib/utils/token/getRoles.ts +++ b/lib/utils/token/getRoles.ts @@ -1,4 +1,5 @@ import { getClaim, getDecodedToken } from "."; +import { getDecodedTokenSync } from "./getDecodedToken"; import { BaseAccountResponse, GetRolesOptions } from "../../types"; import { callAccountApiPaginated } from "./accountApi/callAccountApi"; @@ -11,6 +12,24 @@ type AccountRolesResult = BaseAccountResponse & { }; }; +type TokenWithRoles = { + roles?: Role[]; + "x-hasura-roles"?: Role[]; +} | null; + +const _getRolesCore = (token: TokenWithRoles): Role[] => { + if (!token) { + return []; + } + if (!token.roles && !token["x-hasura-roles"]) { + console.warn( + "No roles found in token, ensure roles have been included in the token customisation within the application settings", + ); + return []; + } + return (token.roles || token["x-hasura-roles"]) as Role[]; +}; + /** * Get all permissions * @returns { Promise } @@ -32,17 +51,18 @@ export const getRoles = async (options?: GetRolesOptions): Promise => { } const token = await getDecodedToken(); + return _getRolesCore(token); +}; - if (!token) { - return []; +export const getRolesSync = (options?: GetRolesOptions): Role[] => { + if (options?.forceApi) { + throw new Error("forceApi cannot be used in sync mode"); } - if (!token.roles && !token["x-hasura-roles"]) { - console.warn( - "No roles found in token, ensure roles have been included in the token customisation within the application settings", - ); - return []; - } + const token = getDecodedTokenSync(); - return token.roles || token["x-hasura-roles"]; + if (!token) { + throw new Error("Authentication token not found."); + } + return _getRolesCore(token); }; diff --git a/lib/utils/token/getUserOrganizations.test.ts b/lib/utils/token/getUserOrganizations.test.ts index 3ee8126..ec0bd42 100644 --- a/lib/utils/token/getUserOrganizations.test.ts +++ b/lib/utils/token/getUserOrganizations.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it, beforeEach } from "vitest"; import { MemoryStorage, StorageKeys } from "../../sessionManager"; -import { setActiveStorage, getUserOrganizations } from "."; +import { + setActiveStorage, + getUserOrganizations, + getUserOrganizationsSync, +} from "."; import { createMockAccessToken } from "./testUtils"; const storage = new MemoryStorage(); @@ -11,7 +15,7 @@ describe("getUserOrganizations", () => { }); it("when token is null", async () => { - await storage.setSessionItem(StorageKeys.idToken, null); + await storage.removeItems(StorageKeys.idToken); const idToken = await getUserOrganizations(); expect(idToken).toStrictEqual(null); @@ -47,3 +51,38 @@ describe("getUserOrganizations", () => { expect(idToken).toStrictEqual(null); }); }); + +describe("getUserOrganizationsSync", () => { + beforeEach(() => { + setActiveStorage(storage); + }); + it("when token is null", () => { + storage.removeSessionItem(StorageKeys.idToken); + const idToken = getUserOrganizationsSync(); + expect(idToken).toStrictEqual(null); + }); + it("When multiple org", () => { + storage.setSessionItem( + StorageKeys.idToken, + createMockAccessToken({ org_codes: ["org_123456789", "org_1234567"] }), + ); + const idToken = getUserOrganizationsSync(); + expect(idToken).toStrictEqual(["org_123456789", "org_1234567"]); + }); + it("When single org", () => { + storage.setSessionItem( + StorageKeys.idToken, + createMockAccessToken({ org_codes: ["org_123456789"] }), + ); + const idToken = getUserOrganizationsSync(); + expect(idToken).toStrictEqual(["org_123456789"]); + }); + it("when no orgs", () => { + storage.setSessionItem( + StorageKeys.idToken, + createMockAccessToken({ org_codes: null }), + ); + const idToken = getUserOrganizationsSync(); + expect(idToken).toStrictEqual(null); + }); +}); diff --git a/lib/utils/token/getUserOrganizations.ts b/lib/utils/token/getUserOrganizations.ts index d7782c5..37eb712 100644 --- a/lib/utils/token/getUserOrganizations.ts +++ b/lib/utils/token/getUserOrganizations.ts @@ -1,4 +1,5 @@ import { getDecodedToken } from "."; +import { getDecodedTokenSync, JWTDecoded } from "./getDecodedToken"; /** * Gets all the code of the organizations the user belongs to. @@ -6,17 +7,25 @@ import { getDecodedToken } from "."; */ export const getUserOrganizations = async (): Promise => { const token = await getDecodedToken("idToken"); + return _getUserOrganizationsCore(token); +}; + +export const getUserOrganizationsSync = (): string[] | null => { + const token = getDecodedTokenSync("idToken"); + return _getUserOrganizationsCore(token); +}; +const _getUserOrganizationsCore = ( + token: JWTDecoded | null, +): string[] | null => { if (!token) { return null; } - if (!token.org_codes && !token["x-hasura-org-codes"]) { console.warn( "Org codes not found in token, ensure org codes have been included in the token customisation within the application settings", ); return null; } - return token.org_codes || token["x-hasura-org-codes"]; }; diff --git a/lib/utils/token/getUserOrganizationsHasura.test.ts b/lib/utils/token/getUserOrganizationsHasura.test.ts index c80fd1b..6f00756 100644 --- a/lib/utils/token/getUserOrganizationsHasura.test.ts +++ b/lib/utils/token/getUserOrganizationsHasura.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it, beforeEach } from "vitest"; import { MemoryStorage, StorageKeys } from "../../sessionManager"; -import { setActiveStorage, getUserOrganizations } from "."; +import { + setActiveStorage, + getUserOrganizations, + getUserOrganizationsSync, +} from "."; import { createMockAccessToken } from "./testUtils"; const storage = new MemoryStorage(); @@ -41,3 +45,17 @@ describe("getUserOrganizations - Hasura", () => { expect(idToken).toStrictEqual(null); }); }); + +describe("getUserOrganizationsSync - Hasura", () => { + beforeEach(() => { + setActiveStorage(storage); + }); + it("When single org", () => { + storage.setSessionItem( + StorageKeys.idToken, + createMockAccessToken({ ["x-hasura-org-codes"]: ["org_123456789"] }), + ); + const idToken = getUserOrganizationsSync(); + expect(idToken).toStrictEqual(["org_123456789"]); + }); +}); diff --git a/lib/utils/token/getUserProfile.test.ts b/lib/utils/token/getUserProfile.test.ts index a5827bc..d8f7a7e 100644 --- a/lib/utils/token/getUserProfile.test.ts +++ b/lib/utils/token/getUserProfile.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, beforeEach, vi } from "vitest"; import { MemoryStorage, StorageKeys } from "../../sessionManager"; -import { getUserProfile, setActiveStorage } from "."; +import { getUserProfile, getUserProfileSync, setActiveStorage } from "."; import { createMockAccessToken } from "./testUtils"; const storage = new MemoryStorage(); @@ -11,7 +11,7 @@ describe("getUserProfile", () => { }); it("when no token", async () => { - await storage.setSessionItem(StorageKeys.idToken, null); + await storage.removeSessionItem(StorageKeys.idToken); const idToken = await getUserProfile(); expect(idToken).toStrictEqual(null); @@ -82,3 +82,36 @@ describe("getUserProfile", () => { expect(consoleMock).toHaveBeenCalledWith("No sub in idToken"); }); }); + +describe("getUserProfileSync", () => { + beforeEach(() => { + setActiveStorage(storage); + }); + + it("when no token", () => { + storage.removeSessionItem(StorageKeys.idToken); + const idToken = getUserProfileSync(); + + expect(idToken).toStrictEqual(null); + }); + + it("maps basic props", () => { + storage.setSessionItem( + StorageKeys.idToken, + createMockAccessToken({ + given_name: "Bob", + family_name: "Kinde", + email: "bob@kinde.com", + picture: "https://kinde.com/", + }), + ); + const idToken = getUserProfileSync(); + expect(idToken).toStrictEqual({ + email: "bob@kinde.com", + familyName: "Kinde", + givenName: "Bob", + id: "kp_cfcb1ae5b9254ad99521214014c54f43", + picture: "https://kinde.com/", + }); + }); +}); diff --git a/lib/utils/token/getUserProfile.ts b/lib/utils/token/getUserProfile.ts index 508daa4..f6151ef 100644 --- a/lib/utils/token/getUserProfile.ts +++ b/lib/utils/token/getUserProfile.ts @@ -1,4 +1,5 @@ import { getClaims } from "."; +import { getClaimsSync } from "./getClaims"; export type UserProfile = { id: string; @@ -8,16 +9,17 @@ export type UserProfile = { picture?: string; }; -export const getUserProfile = async (): Promise< - (UserProfile & T) | null -> => { - const idToken = await getClaims<{ - sub: string; - given_name: string; - family_name: string; - email: string; - picture: string; - }>("idToken"); +const _getUserProfileCore = ( + idToken: + | (T & { + sub: string; + given_name: string; + family_name: string; + email: string; + picture: string; + }) + | null, +): (UserProfile & T) | null => { if (!idToken) { return null; } @@ -34,3 +36,25 @@ export const getUserProfile = async (): Promise< picture: idToken.picture, } as UserProfile & T; }; + +export const getUserProfile = async (): Promise => { + const idToken = await getClaims<{ + sub: string; + given_name: string; + family_name: string; + email: string; + picture: string; + }>("idToken"); + return _getUserProfileCore(idToken); +}; + +export const getUserProfileSync = (): UserProfile | null => { + const idToken = getClaimsSync<{ + sub: string; + given_name: string; + family_name: string; + email: string; + picture: string; + }>("idToken"); + return _getUserProfileCore(idToken); +}; diff --git a/lib/utils/token/has/has.test.ts b/lib/utils/token/has/has.test.ts index ac7e0d9..e148bb8 100644 --- a/lib/utils/token/has/has.test.ts +++ b/lib/utils/token/has/has.test.ts @@ -20,7 +20,7 @@ describe("has", () => { }); it("when no token", async () => { - await storage.setSessionItem(StorageKeys.accessToken, null); + storage.removeSessionItem(StorageKeys.accessToken); try { const result = await has({ roles: ["admin"], permissions: ["canEdit"] }); diff --git a/lib/utils/token/has/hasRoles.test.ts b/lib/utils/token/has/hasRoles.test.ts index f155aeb..61b56a2 100644 --- a/lib/utils/token/has/hasRoles.test.ts +++ b/lib/utils/token/has/hasRoles.test.ts @@ -20,7 +20,7 @@ describe("hasRoles", () => { }); it("when no token", async () => { - await storage.setSessionItem(StorageKeys.accessToken, null); + storage.removeSessionItem(StorageKeys.accessToken); try { const result = await hasRoles({ roles: ["admin"] }); diff --git a/lib/utils/token/index.ts b/lib/utils/token/index.ts index 38008f9..c1ca9e5 100644 --- a/lib/utils/token/index.ts +++ b/lib/utils/token/index.ts @@ -8,21 +8,29 @@ export { hasFeatureFlags, hasBillingEntitlements, } from "./has"; -export { getClaim } from "./getClaim"; -export { getClaims } from "./getClaims"; +export { getClaim, getClaimSync } from "./getClaim"; +export { getClaims, getClaimsSync } from "./getClaims"; export { getCurrentOrganization } from "./getCurrentOrganization"; -export { getDecodedToken } from "./getDecodedToken"; -export { getRawToken } from "./getRawToken"; +export { getCurrentOrganizationSync } from "./getCurrentOrganization"; +export { getDecodedToken, getDecodedTokenSync } from "./getDecodedToken"; +export { getRawToken, getRawTokenSync } from "./getRawToken"; export { getFlag } from "./getFlag"; +export { getFlagSync } from "./getFlag"; export { getFlags } from "./getFlags"; +export { getFlagsSync } from "./getFlags"; export { getUserProfile } from "./getUserProfile"; +export { getUserProfileSync } from "./getUserProfile"; export type { UserProfile } from "./getUserProfile"; export { getPermission } from "./getPermission"; +export { getPermissionSync } from "./getPermission"; export type { PermissionAccess } from "./getPermission"; export { getPermissions } from "./getPermissions"; +export { getPermissionsSync } from "./getPermissions"; export type { Permissions } from "./getPermissions"; export { getUserOrganizations } from "./getUserOrganizations"; +export { getUserOrganizationsSync } from "./getUserOrganizations"; export { getRoles } from "./getRoles"; +export { getRolesSync } from "./getRoles"; export type { Role } from "./getRoles"; export { isAuthenticated } from "./isAuthenticated"; export { isTokenExpired } from "./isTokenExpired";