From 6ae7ec22453640d95fd9c04bd570f8cdd1e7ef56 Mon Sep 17 00:00:00 2001 From: Daniel Rivers Date: Mon, 23 Sep 2024 20:31:09 +0100 Subject: [PATCH 01/17] feat: add isAuthenticated and refreshToken functions --- lib/utils/token/index.ts | 5 ++ lib/utils/token/isAuthenticated.ts | 41 +++++++++++++++++ lib/utils/token/refreshToken.ts | 73 ++++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+) create mode 100644 lib/utils/token/isAuthenticated.ts create mode 100644 lib/utils/token/refreshToken.ts diff --git a/lib/utils/token/index.ts b/lib/utils/token/index.ts index 1ede6e4..ff7f3ca 100644 --- a/lib/utils/token/index.ts +++ b/lib/utils/token/index.ts @@ -10,6 +10,9 @@ import { getPermission, PermissionAccess } from "./getPermission"; import { getPermissions, Permissions } from "./getPermissions"; import { getUserOrganizations } from "./getUserOrganistaions"; import { getRoles } from "./getRoles"; +import { isAuthenticated } from "./isAuthenticated"; +import { refreshToken } from "./refreshToken"; + const storage = { value: null as SessionManager | null, }; @@ -38,6 +41,8 @@ export { getPermissions, getUserOrganizations, getRoles, + isAuthenticated, + refreshToken, }; export type { UserProfile, Permissions, PermissionAccess }; diff --git a/lib/utils/token/isAuthenticated.ts b/lib/utils/token/isAuthenticated.ts new file mode 100644 index 0000000..17159cd --- /dev/null +++ b/lib/utils/token/isAuthenticated.ts @@ -0,0 +1,41 @@ +import { JWTDecoded } from "@kinde/jwt-decoder"; +import { getDecodedToken, refreshToken } from "."; + +export interface IsAuthenticatedPropsWithRefreshToken { + useRefreshToken?: true; + domain?: string; + clientId?: string; +} + +export interface IsAuthenticatedPropsWithoutRefreshToken { + useRefreshToken?: false; + domain?: never; + clientId?: never; +} + +type IsAuthenticatedProps = + | IsAuthenticatedPropsWithRefreshToken + | IsAuthenticatedPropsWithoutRefreshToken; + +/** + * check if the user is authenticated with option to refresh the token + * @returns { Promise } + */ +export const isAuthenticated = async ( + props?: IsAuthenticatedProps, +): Promise => { + try { + const token = await getDecodedToken("accessToken"); + if (!token) return false; + + const isExpired = token.exp < Math.floor(Date.now() / 1000); + + if (isExpired && props?.useRefreshToken) { + return refreshToken(props.domain, props.clientId); + } + return !isExpired; + } catch (error) { + console.error("Error checking authentication:", error); + return false; + } +}; diff --git a/lib/utils/token/refreshToken.ts b/lib/utils/token/refreshToken.ts new file mode 100644 index 0000000..6bd1e39 --- /dev/null +++ b/lib/utils/token/refreshToken.ts @@ -0,0 +1,73 @@ +import { getActiveStorage } from "."; +import { StorageKeys } from "../../sessionManager"; +import { sanatizeURL } from "../sanatizeUrl"; + +/** + * refreshes the token + * @returns { Promise } + */ +export const refreshToken = async ( + domain?: string, + clientId?: string, +): Promise => { + try { + if (!domain) { + console.error("Domain is required for token refresh"); + return false; + } + + if (!clientId) { + console.error("Client ID is required for token refresh"); + return false; + } + + const storage = getActiveStorage(); + const refreshTokenValue = await storage.getSessionItem( + StorageKeys.refreshToken, + ); + + if (!refreshTokenValue) { + console.error("No refresh token found"); + return false; + } + + const response = await fetch(`${sanatizeURL(domain)}/oauth2/token`, { + method: "POST", + headers: { + "Content-Type": "multipart/form-data", + "Kinde-SDK": "js-utils", + }, + body: JSON.stringify({ + refresh_token: refreshTokenValue, + grant_type: "refresh_token", + client_id: clientId, + }), + }); + + if (!response.ok) { + console.error("Failed to refresh token"); + return false; + } + + const data = await response.json(); + + if (data.access_token) { + await storage.setSessionItem(StorageKeys.accessToken, data.access_token); + if (data.id_token) { + await storage.setSessionItem(StorageKeys.idToken, data.id_token); + } + if (data.refresh_token) { + await storage.setSessionItem( + StorageKeys.refreshToken, + data.refresh_token, + ); + } + return true; + } + + return false; + } catch (error) { + console.error("Error refreshing token:", error); + return false; + } +}; From c765e7dc452fc7d7a6f47bfe7c7fe7f95cab4e94 Mon Sep 17 00:00:00 2001 From: Daniel Rivers Date: Mon, 23 Sep 2024 22:33:49 +0100 Subject: [PATCH 02/17] test: add tests for isAuthenticated and refreshToken --- lib/utils/token/isAuthenticated.test.ts | 94 +++++++++++++++++ lib/utils/token/refreshToken.test.ts | 135 ++++++++++++++++++++++++ lib/utils/token/refreshToken.ts | 5 +- 3 files changed, 232 insertions(+), 2 deletions(-) create mode 100644 lib/utils/token/isAuthenticated.test.ts create mode 100644 lib/utils/token/refreshToken.test.ts diff --git a/lib/utils/token/isAuthenticated.test.ts b/lib/utils/token/isAuthenticated.test.ts new file mode 100644 index 0000000..417b33d --- /dev/null +++ b/lib/utils/token/isAuthenticated.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { isAuthenticated } from "."; +import * as tokenUtils from "."; + +// Mock the entire token utils module +vi.mock(".."); + +describe("isAuthenticated", () => { + const mockCurrentTime = 1000000000; + + beforeEach(() => { + // Mock Date.now() to return a fixed timestamp + vi.spyOn(Date, "now").mockImplementation(() => mockCurrentTime * 1000); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should return false if no token is found", async () => { + vi.spyOn(tokenUtils, "getDecodedToken").mockResolvedValue(null); + + const result = await isAuthenticated(); + + expect(result).toBe(false); + }); + + it("should return true if token is valid and not expired", async () => { + vi.spyOn(tokenUtils, "getDecodedToken").mockResolvedValue({ + exp: mockCurrentTime + 3600, + }); + + const result = await isAuthenticated(); + + expect(result).toBe(true); + }); + + it("should return false if token is expired and useRefreshToken is not set", async () => { + vi.spyOn(tokenUtils, "getDecodedToken").mockResolvedValue({ + exp: mockCurrentTime - 3600, + }); + + const result = await isAuthenticated(); + + expect(result).toBe(false); + }); + + it("should attempt to refresh token if expired and useRefreshToken is true", async () => { + vi.spyOn(tokenUtils, "getDecodedToken").mockResolvedValue({ + exp: mockCurrentTime - 3600, + }); + const mockRefreshToken = vi + .spyOn(tokenUtils, "refreshToken") + .mockResolvedValue(true); + + const result = await isAuthenticated({ + useRefreshToken: true, + domain: "test.com", + clientId: "123", + }); + + expect(result).toBe(true); + expect(mockRefreshToken).toHaveBeenCalledWith("test.com", "123"); + }); + + it("should return false if token refresh fails", async () => { + vi.spyOn(tokenUtils, "getDecodedToken").mockResolvedValue({ + exp: mockCurrentTime - 3600, + }); + vi.spyOn(tokenUtils, "refreshToken").mockResolvedValue(false); + + const result = await isAuthenticated({ + useRefreshToken: true, + domain: "test.com", + clientId: "123", + }); + + expect(result).toBe(false); + }); + + it("should return false and log error if an exception occurs", async () => { + const mockError = new Error("Test error"); + vi.spyOn(tokenUtils, "getDecodedToken").mockRejectedValue(mockError); + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const result = await isAuthenticated(); + + expect(result).toBe(false); + expect(consoleSpy).toHaveBeenCalledWith( + "Error checking authentication:", + mockError, + ); + }); +}); diff --git a/lib/utils/token/refreshToken.test.ts b/lib/utils/token/refreshToken.test.ts new file mode 100644 index 0000000..90b9d0b --- /dev/null +++ b/lib/utils/token/refreshToken.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { refreshToken } from "."; +import { SessionManager, StorageKeys } from "../../sessionManager"; +import * as tokenUtils from "."; + +describe("refreshToken", () => { + const mockDomain = "https://example.com"; + const mockClientId = "test-client-id"; + const mockRefreshTokenValue = "mock-refresh-token"; + const mockStorage: SessionManager = { + getSessionItem: vi.fn(), + setSessionItem: vi.fn(), + removeSessionItem: vi.fn(), + destroySession: vi.fn(), + }; + + beforeEach(() => { + vi.resetAllMocks(); + vi.spyOn(tokenUtils, "getDecodedToken").mockResolvedValue(null); + vi.spyOn(tokenUtils, "getActiveStorage").mockResolvedValue(mockStorage); + // vi.spyOn(Utils, 'sanatizeURL').mockImplementation((url) => url); + global.fetch = vi.fn(); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should return false if domain is not provided", async () => { + const result = await refreshToken(undefined, mockClientId); + expect(result).toBe(false); + expect(console.error).toHaveBeenCalledWith( + "Domain is required for token refresh", + ); + }); + + it("should return false if clientId is not provided", async () => { + const result = await refreshToken(mockDomain, undefined); + expect(result).toBe(false); + expect(console.error).toHaveBeenCalledWith( + "Client ID is required for token refresh", + ); + }); + + it("should return false if no refresh token is found", async () => { + // mockStorage.getSessionItem.mockResolvedValue(null); + const result = await refreshToken(mockDomain, mockClientId); + expect(result).toBe(false); + expect(console.error).toHaveBeenCalledWith("No refresh token found"); + }); + + it("should return false if the fetch request fails", async () => { + mockStorage.getSessionItem = vi + .fn() + .mockResolvedValue(mockRefreshTokenValue); + vi.mocked(global.fetch).mockRejectedValue(new Error("Network error")); + const result = await refreshToken(mockDomain, mockClientId); + expect(result).toBe(false); + expect(console.error).toHaveBeenCalledWith( + "Error refreshing token:", + expect.any(Error), + ); + }); + + it("should return false if the response is not ok", async () => { + mockStorage.getSessionItem = vi + .fn() + .mockResolvedValue(mockRefreshTokenValue); + vi.mocked(global.fetch).mockResolvedValue({ ok: false } as Response); + const result = await refreshToken(mockDomain, mockClientId); + expect(result).toBe(false); + expect(console.error).toHaveBeenCalledWith("Failed to refresh token"); + }); + + it("should return false if the response does not contain an access token", async () => { + mockStorage.getSessionItem = vi + .fn() + .mockResolvedValue(mockRefreshTokenValue); + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: () => Promise.resolve({}), + } as Response); + const result = await refreshToken(mockDomain, mockClientId); + expect(result).toBe(false); + }); + + it("should return true and update tokens if the refresh is successful", async () => { + const mockResponse = { + access_token: "new-access-token", + id_token: "new-id-token", + refresh_token: "new-refresh-token", + }; + mockStorage.getSessionItem = vi + .fn() + .mockResolvedValue(mockRefreshTokenValue); + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse), + } as Response); + + const result = await refreshToken(mockDomain, mockClientId); + + expect(result).toBe(true); + expect(mockStorage.setSessionItem).toHaveBeenCalledWith( + StorageKeys.accessToken, + "new-access-token", + ); + expect(mockStorage.setSessionItem).toHaveBeenCalledWith( + StorageKeys.idToken, + "new-id-token", + ); + expect(mockStorage.setSessionItem).toHaveBeenCalledWith( + StorageKeys.refreshToken, + "new-refresh-token", + ); + }); + + it("should use sanatizeURL for the domain", async () => { + mockStorage.getSessionItem = vi + .fn() + .mockResolvedValue(mockRefreshTokenValue); + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ access_token: "new-token" }), + } as Response); + + await refreshToken("https://example.com/", mockClientId); + + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining(`https://example.com/oauth2/token`), + expect.any(Object), + ); + }); +}); diff --git a/lib/utils/token/refreshToken.ts b/lib/utils/token/refreshToken.ts index 6bd1e39..bb5b476 100644 --- a/lib/utils/token/refreshToken.ts +++ b/lib/utils/token/refreshToken.ts @@ -1,6 +1,6 @@ import { getActiveStorage } from "."; import { StorageKeys } from "../../sessionManager"; -import { sanatizeURL } from "../sanatizeUrl"; +import { sanatizeURL } from ".."; /** * refreshes the token @@ -21,7 +21,8 @@ export const refreshToken = async ( return false; } - const storage = getActiveStorage(); + const storage = await getActiveStorage(); + const refreshTokenValue = await storage.getSessionItem( StorageKeys.refreshToken, ); From 721f08816ab7e2e2a523a3dc9292612257165c6b Mon Sep 17 00:00:00 2001 From: Daniel Rivers Date: Sat, 12 Oct 2024 23:46:04 +0100 Subject: [PATCH 03/17] test: add new methods to export test --- lib/main.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/main.test.ts b/lib/main.test.ts index 79d75ea..ecb877a 100644 --- a/lib/main.test.ts +++ b/lib/main.test.ts @@ -39,6 +39,8 @@ describe("index exports", () => { "generateRandomString", "mapLoginMethodParamsForUrl", "sanatizeURL", + "isAuthenticated", + "refreshToken", // session manager "MemoryStorage", From 561489c63b16d1460fc130653d4245ac3a29be0f Mon Sep 17 00:00:00 2001 From: Daniel Rivers Date: Wed, 30 Oct 2024 15:50:51 +0000 Subject: [PATCH 04/17] fix: IsAuthenticatedPropsWithRefreshToken interface --- lib/utils/token/isAuthenticated.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/utils/token/isAuthenticated.ts b/lib/utils/token/isAuthenticated.ts index 17159cd..c30b594 100644 --- a/lib/utils/token/isAuthenticated.ts +++ b/lib/utils/token/isAuthenticated.ts @@ -3,8 +3,8 @@ import { getDecodedToken, refreshToken } from "."; export interface IsAuthenticatedPropsWithRefreshToken { useRefreshToken?: true; - domain?: string; - clientId?: string; + domain: string; + clientId: string; } export interface IsAuthenticatedPropsWithoutRefreshToken { From c7b873e218348944709d66f5f21b342798bcea33 Mon Sep 17 00:00:00 2001 From: Daniel Rivers Date: Thu, 31 Oct 2024 22:35:28 +0000 Subject: [PATCH 05/17] feat: updates --- lib/utils/base64UrlEncode.ts | 11 ++++++++++- lib/utils/exchangeAuthCode.ts | 12 ++++++------ lib/utils/generateAuthUrl.ts | 13 +++++++------ lib/utils/token/isAuthenticated.test.ts | 10 ++++++++++ lib/utils/token/isAuthenticated.ts | 5 +++++ lib/utils/token/refreshToken.ts | 12 +++++++++--- 6 files changed, 47 insertions(+), 16 deletions(-) diff --git a/lib/utils/base64UrlEncode.ts b/lib/utils/base64UrlEncode.ts index f9ab32a..ea077c9 100644 --- a/lib/utils/base64UrlEncode.ts +++ b/lib/utils/base64UrlEncode.ts @@ -3,7 +3,15 @@ * @param str String to encode * @returns encoded string */ -export const base64UrlEncode = (str: string): string => { +export const base64UrlEncode = (str: string | ArrayBuffer): string => { + if (str instanceof ArrayBuffer) { + const numberArray = Array.from(new Uint8Array(str)); + return btoa(String.fromCharCode.apply(null, numberArray)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + } + const encoder = new TextEncoder(); const uintArray = encoder.encode(str); const charArray = Array.from(uintArray); @@ -11,4 +19,5 @@ export const base64UrlEncode = (str: string): string => { .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=+$/, ""); + }; diff --git a/lib/utils/exchangeAuthCode.ts b/lib/utils/exchangeAuthCode.ts index 0e85c70..c41b16f 100644 --- a/lib/utils/exchangeAuthCode.ts +++ b/lib/utils/exchangeAuthCode.ts @@ -71,13 +71,13 @@ export const exchangeAuthCode = async ({ const headers: { "Content-type": string; - "Cache-Control": string; - Pragma: string; + // "Cache-Control": string; + // Pragma: string; "Kinde-SDK"?: string; } = { "Content-type": "application/x-www-form-urlencoded; charset=UTF-8", - "Cache-Control": "no-store", - Pragma: "no-cache", + // "Cache-Control": "no-store", + // Pragma: "no-cache", }; if (frameworkSettings.framework) { @@ -88,8 +88,8 @@ export const exchangeAuthCode = async ({ const response = await fetch(`${domain}/oauth2/token`, { method: "POST", // ...(isUseCookie && {credentials: 'include'}), - credentials: "include", - headers, + // credentials: "include", + headers: new Headers(headers), body: new URLSearchParams({ client_id: clientId, code, diff --git a/lib/utils/generateAuthUrl.ts b/lib/utils/generateAuthUrl.ts index abefee0..26d7be4 100644 --- a/lib/utils/generateAuthUrl.ts +++ b/lib/utils/generateAuthUrl.ts @@ -13,7 +13,7 @@ export const generateAuthUrl = async ( domain: string, type: IssuerRouteTypes = IssuerRouteTypes.login, options: LoginOptions, -): Promise<{ url: URL; state: string; nonce: string }> => { +): Promise<{ url: URL; state: string; nonce: string; codeChallenge: string }> => { const authUrl = new URL(`${domain}/oauth2/auth`); const activeStorage = getActiveStorage(); const searchParams: Record = { @@ -59,18 +59,19 @@ export const generateAuthUrl = async ( url: authUrl, state: searchParams["state"], nonce: searchParams["nonce"], + codeChallenge: searchParams["code_challenge"], }; }; -async function generatePKCEPair(): Promise<{ +export async function generatePKCEPair(): Promise<{ codeVerifier: string; codeChallenge: string; }> { - const codeVerifier = generateRandomString(43); + const codeVerifier = generateRandomString(52); const data = new TextEncoder().encode(codeVerifier); const hashed = await crypto.subtle.digest("SHA-256", data); - const hashArray = Array.from(new Uint8Array(hashed)); - const hashString = hashArray.map((b) => String.fromCharCode(b)).join(""); - const codeChallenge = base64UrlEncode(hashString); + // const hashArray = Array.from(new Uint8Array(hashed)); + // const hashString = hashArray.map((b) => String.fromCharCode(b)).join(""); + const codeChallenge = base64UrlEncode(hashed); return { codeVerifier, codeChallenge }; } diff --git a/lib/utils/token/isAuthenticated.test.ts b/lib/utils/token/isAuthenticated.test.ts index 417b33d..8716218 100644 --- a/lib/utils/token/isAuthenticated.test.ts +++ b/lib/utils/token/isAuthenticated.test.ts @@ -91,4 +91,14 @@ describe("isAuthenticated", () => { mockError, ); }); + + it("should return false if token is missing exp", async () => { + vi.spyOn(tokenUtils, "getDecodedToken").mockResolvedValue({ + // Missing 'exp' field + }); + + const result = await isAuthenticated(); + + expect(result).toBe(false); + }); }); diff --git a/lib/utils/token/isAuthenticated.ts b/lib/utils/token/isAuthenticated.ts index c30b594..b3fc8ef 100644 --- a/lib/utils/token/isAuthenticated.ts +++ b/lib/utils/token/isAuthenticated.ts @@ -28,6 +28,11 @@ export const isAuthenticated = async ( const token = await getDecodedToken("accessToken"); if (!token) return false; + if (!token.exp) { + console.error("Token does not have an expiry"); + return false; + } + const isExpired = token.exp < Math.floor(Date.now() / 1000); if (isExpired && props?.useRefreshToken) { diff --git a/lib/utils/token/refreshToken.ts b/lib/utils/token/refreshToken.ts index bb5b476..5a1d5a2 100644 --- a/lib/utils/token/refreshToken.ts +++ b/lib/utils/token/refreshToken.ts @@ -7,8 +7,8 @@ import { sanatizeURL } from ".."; * @returns { Promise } */ export const refreshToken = async ( - domain?: string, - clientId?: string, + domain: string, + clientId: string, ): Promise => { try { if (!domain) { @@ -23,9 +23,15 @@ export const refreshToken = async ( const storage = await getActiveStorage(); + if (!storage) { + console.error("No active storage found"); + return false; + } + const refreshTokenValue = await storage.getSessionItem( StorageKeys.refreshToken, - ); + ) as string; + if (!refreshTokenValue) { console.error("No refresh token found"); From 742f5a7005b5ec16311f8350a4bae776bb9c52ae Mon Sep 17 00:00:00 2001 From: Daniel Rivers Date: Thu, 31 Oct 2024 22:35:58 +0000 Subject: [PATCH 06/17] fix: correct spelling of sanitizeUrl --- lib/main.test.ts | 2 +- lib/utils/index.ts | 4 ++-- lib/utils/mapLoginMethodParamsForUrl.ts | 4 ++-- lib/utils/{sanatizeUrl.ts => sanitizeUrl.ts} | 2 +- lib/utils/token/refreshToken.test.ts | 23 ++++++++++---------- lib/utils/token/refreshToken.ts | 4 ++-- 6 files changed, 20 insertions(+), 19 deletions(-) rename lib/utils/{sanatizeUrl.ts => sanitizeUrl.ts} (57%) diff --git a/lib/main.test.ts b/lib/main.test.ts index 264c56b..707068e 100644 --- a/lib/main.test.ts +++ b/lib/main.test.ts @@ -38,7 +38,7 @@ describe("index exports", () => { "generateAuthUrl", "generateRandomString", "mapLoginMethodParamsForUrl", - "sanatizeURL", + "sanitizeUrl", "exchangeAuthCode", "isAuthenticated", "refreshToken", diff --git a/lib/utils/index.ts b/lib/utils/index.ts index 74fa20c..ac5b8ad 100644 --- a/lib/utils/index.ts +++ b/lib/utils/index.ts @@ -2,7 +2,7 @@ import { base64UrlEncode } from "./base64UrlEncode"; import { generateRandomString } from "./generateRandomString"; import { extractAuthResults } from "./extractAuthResults"; -import { sanatizeURL } from "./sanatizeUrl"; +import { sanitizeUrl } from "./sanitizeUrl"; import { generateAuthUrl } from "./generateAuthUrl"; import { mapLoginMethodParamsForUrl } from "./mapLoginMethodParamsForUrl"; import { exchangeAuthCode, frameworkSettings } from "./exchangeAuthCode"; @@ -15,7 +15,7 @@ export { base64UrlEncode, generateRandomString, extractAuthResults, - sanatizeURL, + sanitizeUrl, generateAuthUrl, mapLoginMethodParamsForUrl, exchangeAuthCode, diff --git a/lib/utils/mapLoginMethodParamsForUrl.ts b/lib/utils/mapLoginMethodParamsForUrl.ts index 1d6a61c..c3d2225 100644 --- a/lib/utils/mapLoginMethodParamsForUrl.ts +++ b/lib/utils/mapLoginMethodParamsForUrl.ts @@ -1,5 +1,5 @@ import { LoginMethodParams } from "../types"; -import { sanatizeURL } from "./sanatizeUrl"; +import { sanitizeUrl } from "./sanitizeUrl"; export const mapLoginMethodParamsForUrl = ( options: Partial, @@ -9,7 +9,7 @@ export const mapLoginMethodParamsForUrl = ( is_create_org: options.isCreateOrg?.toString(), connection_id: options.connectionId, redirect_uri: options.redirectURL - ? sanatizeURL(options.redirectURL) + ? sanitizeUrl(options.redirectURL) : undefined, audience: options.audience || "", scope: options.scope?.join(" ") || "email profile openid offline", diff --git a/lib/utils/sanatizeUrl.ts b/lib/utils/sanitizeUrl.ts similarity index 57% rename from lib/utils/sanatizeUrl.ts rename to lib/utils/sanitizeUrl.ts index 853b3b1..22b32b5 100644 --- a/lib/utils/sanatizeUrl.ts +++ b/lib/utils/sanitizeUrl.ts @@ -1,4 +1,4 @@ //function to remove trailing slash -export const sanatizeURL = (url: string): string => { +export const sanitizeUrl = (url: string): string => { return url.replace(/\/$/, ""); }; diff --git a/lib/utils/token/refreshToken.test.ts b/lib/utils/token/refreshToken.test.ts index 90b9d0b..9579b39 100644 --- a/lib/utils/token/refreshToken.test.ts +++ b/lib/utils/token/refreshToken.test.ts @@ -1,5 +1,4 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { refreshToken } from "."; import { SessionManager, StorageKeys } from "../../sessionManager"; import * as tokenUtils from "."; @@ -12,13 +11,15 @@ describe("refreshToken", () => { setSessionItem: vi.fn(), removeSessionItem: vi.fn(), destroySession: vi.fn(), + setItems: vi.fn(), + removeItems: vi.fn(), }; beforeEach(() => { vi.resetAllMocks(); vi.spyOn(tokenUtils, "getDecodedToken").mockResolvedValue(null); vi.spyOn(tokenUtils, "getActiveStorage").mockResolvedValue(mockStorage); - // vi.spyOn(Utils, 'sanatizeURL').mockImplementation((url) => url); + // vi.spyOn(Utils, 'sanitizeUrl').mockImplementation((url) => url); global.fetch = vi.fn(); vi.spyOn(console, "error").mockImplementation(() => {}); }); @@ -28,7 +29,7 @@ describe("refreshToken", () => { }); it("should return false if domain is not provided", async () => { - const result = await refreshToken(undefined, mockClientId); + const result = await tokenUtils.refreshToken("", mockClientId); expect(result).toBe(false); expect(console.error).toHaveBeenCalledWith( "Domain is required for token refresh", @@ -36,7 +37,7 @@ describe("refreshToken", () => { }); it("should return false if clientId is not provided", async () => { - const result = await refreshToken(mockDomain, undefined); + const result = await tokenUtils.refreshToken(mockDomain, ""); expect(result).toBe(false); expect(console.error).toHaveBeenCalledWith( "Client ID is required for token refresh", @@ -45,7 +46,7 @@ describe("refreshToken", () => { it("should return false if no refresh token is found", async () => { // mockStorage.getSessionItem.mockResolvedValue(null); - const result = await refreshToken(mockDomain, mockClientId); + const result = await tokenUtils.refreshToken(mockDomain, mockClientId); expect(result).toBe(false); expect(console.error).toHaveBeenCalledWith("No refresh token found"); }); @@ -55,7 +56,7 @@ describe("refreshToken", () => { .fn() .mockResolvedValue(mockRefreshTokenValue); vi.mocked(global.fetch).mockRejectedValue(new Error("Network error")); - const result = await refreshToken(mockDomain, mockClientId); + const result = await tokenUtils.refreshToken(mockDomain, mockClientId); expect(result).toBe(false); expect(console.error).toHaveBeenCalledWith( "Error refreshing token:", @@ -68,7 +69,7 @@ describe("refreshToken", () => { .fn() .mockResolvedValue(mockRefreshTokenValue); vi.mocked(global.fetch).mockResolvedValue({ ok: false } as Response); - const result = await refreshToken(mockDomain, mockClientId); + const result = await tokenUtils.refreshToken(mockDomain, mockClientId); expect(result).toBe(false); expect(console.error).toHaveBeenCalledWith("Failed to refresh token"); }); @@ -81,7 +82,7 @@ describe("refreshToken", () => { ok: true, json: () => Promise.resolve({}), } as Response); - const result = await refreshToken(mockDomain, mockClientId); + const result = await tokenUtils.refreshToken(mockDomain, mockClientId); expect(result).toBe(false); }); @@ -99,7 +100,7 @@ describe("refreshToken", () => { json: () => Promise.resolve(mockResponse), } as Response); - const result = await refreshToken(mockDomain, mockClientId); + const result = await tokenUtils.refreshToken(mockDomain, mockClientId); expect(result).toBe(true); expect(mockStorage.setSessionItem).toHaveBeenCalledWith( @@ -116,7 +117,7 @@ describe("refreshToken", () => { ); }); - it("should use sanatizeURL for the domain", async () => { + it("should use sanitizeUrl for the domain", async () => { mockStorage.getSessionItem = vi .fn() .mockResolvedValue(mockRefreshTokenValue); @@ -125,7 +126,7 @@ describe("refreshToken", () => { json: () => Promise.resolve({ access_token: "new-token" }), } as Response); - await refreshToken("https://example.com/", mockClientId); + await tokenUtils.refreshToken("https://example.com/", mockClientId); expect(global.fetch).toHaveBeenCalledWith( expect.stringContaining(`https://example.com/oauth2/token`), diff --git a/lib/utils/token/refreshToken.ts b/lib/utils/token/refreshToken.ts index 5a1d5a2..1e060a7 100644 --- a/lib/utils/token/refreshToken.ts +++ b/lib/utils/token/refreshToken.ts @@ -1,6 +1,6 @@ import { getActiveStorage } from "."; import { StorageKeys } from "../../sessionManager"; -import { sanatizeURL } from ".."; +import { sanitizeUrl } from ".."; /** * refreshes the token @@ -38,7 +38,7 @@ export const refreshToken = async ( return false; } - const response = await fetch(`${sanatizeURL(domain)}/oauth2/token`, { + const response = await fetch(`${sanitizeUrl(domain)}/oauth2/token`, { method: "POST", headers: { "Content-Type": "multipart/form-data", From c0abe4e5f3debee6325a4ab6891bf14612a7cb7e Mon Sep 17 00:00:00 2001 From: Daniel Rivers Date: Fri, 8 Nov 2024 01:02:01 +0000 Subject: [PATCH 07/17] feat: insecure storage support --- lib/utils/exchangeAuthCode.ts | 9 ++--- lib/utils/generateAuthUrl.ts | 4 +-- lib/utils/token/getDecodedToken.ts | 10 ++++-- lib/utils/token/index.ts | 54 +++++++++++++++++++++++++++--- 4 files changed, 64 insertions(+), 13 deletions(-) diff --git a/lib/utils/exchangeAuthCode.ts b/lib/utils/exchangeAuthCode.ts index c41b16f..9f99bcd 100644 --- a/lib/utils/exchangeAuthCode.ts +++ b/lib/utils/exchangeAuthCode.ts @@ -1,4 +1,4 @@ -import { getActiveStorage, StorageKeys } from "../main"; +import { getActiveStorage, getInsecureStorage, StorageKeys } from "../main"; export const frameworkSettings: { framework: string; @@ -40,7 +40,7 @@ export const exchangeAuthCode = async ({ }; } - const activeStorage = getActiveStorage(); + const activeStorage = getInsecureStorage(); if (!activeStorage) { console.error("No active storage found"); return { @@ -113,13 +113,14 @@ export const exchangeAuthCode = async ({ refresh_token: string; } = await response.json(); - activeStorage.setItems({ + const secureStore = getActiveStorage(); + secureStore!.setItems({ [StorageKeys.accessToken]: data.access_token, [StorageKeys.idToken]: data.id_token, [StorageKeys.refreshToken]: data.refresh_token, }); - await activeStorage.removeItems(StorageKeys.state, StorageKeys.codeVerifier); + await activeStorage.removeItems(StorageKeys.state, StorageKeys.nonce, StorageKeys.codeVerifier); // Clear all url params const cleanUrl = (url: URL): URL => { diff --git a/lib/utils/generateAuthUrl.ts b/lib/utils/generateAuthUrl.ts index 26d7be4..1a349af 100644 --- a/lib/utils/generateAuthUrl.ts +++ b/lib/utils/generateAuthUrl.ts @@ -1,4 +1,4 @@ -import { base64UrlEncode, getActiveStorage, StorageKeys } from "../main"; +import { base64UrlEncode, getInsecureStorage, StorageKeys } from "../main"; import { IssuerRouteTypes, LoginOptions } from "../types"; import { generateRandomString } from "./generateRandomString"; import { mapLoginMethodParamsForUrl } from "./mapLoginMethodParamsForUrl"; @@ -15,7 +15,7 @@ export const generateAuthUrl = async ( options: LoginOptions, ): Promise<{ url: URL; state: string; nonce: string; codeChallenge: string }> => { const authUrl = new URL(`${domain}/oauth2/auth`); - const activeStorage = getActiveStorage(); + const activeStorage = getInsecureStorage(); const searchParams: Record = { client_id: options.clientId, response_type: options.responseType || "code", diff --git a/lib/utils/token/getDecodedToken.ts b/lib/utils/token/getDecodedToken.ts index 5b70617..8d3740d 100644 --- a/lib/utils/token/getDecodedToken.ts +++ b/lib/utils/token/getDecodedToken.ts @@ -12,7 +12,7 @@ export const getDecodedToken = async < org_code: string; }, >( - tokenType: "accessToken" | "idToken" = "accessToken", + tokenType: "accessToken" | "idToken" = StorageKeys.accessToken, ): Promise => { const activeStorage = getActiveStorage(); @@ -28,5 +28,11 @@ export const getDecodedToken = async < return null; } - return jwtDecoder(token); + const decodedToken = jwtDecoder(token); + + if (!decodedToken) { + console.log("No decoded token found"); + } + + return decodedToken; }; diff --git a/lib/utils/token/index.ts b/lib/utils/token/index.ts index 642f324..a8406a7 100644 --- a/lib/utils/token/index.ts +++ b/lib/utils/token/index.ts @@ -14,7 +14,8 @@ import { isAuthenticated } from "./isAuthenticated"; import { refreshToken } from "./refreshToken"; const storage = { - value: null as SessionManager | null, + secure: null as SessionManager | null, + insecure: null as SessionManager | null, }; /** @@ -22,7 +23,7 @@ const storage = { * @param store Session manager instance */ const setActiveStorage = (store: SessionManager) => { - storage.value = store; + storage.secure = store; }; /** @@ -30,7 +31,7 @@ const setActiveStorage = (store: SessionManager) => { * @returns Session manager instance or null */ const getActiveStorage = (): SessionManager | null => { - return storage.value || null; + return storage.secure || null; }; /** @@ -38,18 +39,61 @@ const getActiveStorage = (): SessionManager | null => { * @returns boolean */ const hasActiveStorage = (): boolean => { - return storage.value !== null; + return storage.secure !== null; }; +/** + * Clears the active storage + */ const clearActiveStorage = (): void => { - storage.value = null; + storage.secure = null; +}; + +/** + * Sets the active storage + * @param store Session manager instance + */ +const setInsecureStorage = (store: SessionManager) => { + storage.insecure = store; +}; + +/** + * Gets the current active storage + * @returns Session manager instance or null + */ +const getInsecureStorage = (): SessionManager | null => { + return storage.insecure || storage.secure || null; +}; + +/** + * Checks if there is an active storage + * @returns boolean + */ +const hasInsecureStorage = (): boolean => { + return storage.insecure !== null; +}; + +/** + * Clears the active storage + */ +const clearInsecureStorage = (): void => { + storage.insecure = null; }; export { + // main store setActiveStorage, getActiveStorage, hasActiveStorage, clearActiveStorage, + + // insecure store + setInsecureStorage, + getInsecureStorage, + hasInsecureStorage, + clearInsecureStorage, + + // helpers getClaim, getClaims, getCurrentOrganization, From ce4f2ce451001d4f56d5c914f47e378bc2d5b658 Mon Sep 17 00:00:00 2001 From: Daniel Rivers Date: Fri, 8 Nov 2024 01:02:22 +0000 Subject: [PATCH 08/17] fix: vite exports for builds --- lib/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/main.ts b/lib/main.ts index cc4b46a..f63cd28 100644 --- a/lib/main.ts +++ b/lib/main.ts @@ -1,4 +1,4 @@ export * from "./types"; export * from "./utils"; export * from "./sessionManager"; -export * from "./utils/token/index.ts"; +export * from "./utils/token"; From c2c875f94b7beeb8a2ffdba95214357326cd03d5 Mon Sep 17 00:00:00 2001 From: Daniel Rivers Date: Sun, 10 Nov 2024 22:53:13 +0000 Subject: [PATCH 09/17] feat: auto refresh token after code exchange --- lib/utils/base64UrlEncode.ts | 9 ++++----- lib/utils/exchangeAuthCode.ts | 33 +++++++++++++++++++++++++++------ lib/utils/generateAuthUrl.ts | 7 ++++++- lib/utils/refreshTimer.ts | 12 ++++++++++++ lib/utils/token/refreshToken.ts | 18 ++++++++++++------ 5 files changed, 61 insertions(+), 18 deletions(-) create mode 100644 lib/utils/refreshTimer.ts diff --git a/lib/utils/base64UrlEncode.ts b/lib/utils/base64UrlEncode.ts index ea077c9..5f8e667 100644 --- a/lib/utils/base64UrlEncode.ts +++ b/lib/utils/base64UrlEncode.ts @@ -7,11 +7,11 @@ export const base64UrlEncode = (str: string | ArrayBuffer): string => { if (str instanceof ArrayBuffer) { const numberArray = Array.from(new Uint8Array(str)); return btoa(String.fromCharCode.apply(null, numberArray)) - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); } - + const encoder = new TextEncoder(); const uintArray = encoder.encode(str); const charArray = Array.from(uintArray); @@ -19,5 +19,4 @@ export const base64UrlEncode = (str: string | ArrayBuffer): string => { .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=+$/, ""); - }; diff --git a/lib/utils/exchangeAuthCode.ts b/lib/utils/exchangeAuthCode.ts index 9f99bcd..35d5b13 100644 --- a/lib/utils/exchangeAuthCode.ts +++ b/lib/utils/exchangeAuthCode.ts @@ -1,4 +1,10 @@ -import { getActiveStorage, getInsecureStorage, StorageKeys } from "../main"; +import { + getActiveStorage, + getInsecureStorage, + refreshToken, + StorageKeys, +} from "../main"; +import { clearRefreshTimer, setRefreshTimer } from "./refreshTimer"; export const frameworkSettings: { framework: string; @@ -13,6 +19,7 @@ interface ExchangeAuthCodeParams { domain: string; clientId: string; redirectURL: string; + autoReferesh?: boolean; } interface ExchangeAuthCodeResult { @@ -28,6 +35,7 @@ export const exchangeAuthCode = async ({ domain, clientId, redirectURL, + autoReferesh = false, }: ExchangeAuthCodeParams): Promise => { const state = urlParams.get("state"); const code = urlParams.get("code"); @@ -69,15 +77,16 @@ export const exchangeAuthCode = async ({ StorageKeys.codeVerifier, )) as string; + const headers: { "Content-type": string; - // "Cache-Control": string; - // Pragma: string; + "Cache-Control": string; + Pragma: string; "Kinde-SDK"?: string; } = { "Content-type": "application/x-www-form-urlencoded; charset=UTF-8", - // "Cache-Control": "no-store", - // Pragma: "no-cache", + "Cache-Control": "no-store", + Pragma: "no-cache", }; if (frameworkSettings.framework) { @@ -106,11 +115,13 @@ export const exchangeAuthCode = async ({ error: `Token exchange failed: ${response.status} - ${errorText}`, }; } + clearRefreshTimer() const data: { access_token: string; id_token: string; refresh_token: string; + expires_in: number; } = await response.json(); const secureStore = getActiveStorage(); @@ -120,7 +131,17 @@ export const exchangeAuthCode = async ({ [StorageKeys.refreshToken]: data.refresh_token, }); - await activeStorage.removeItems(StorageKeys.state, StorageKeys.nonce, StorageKeys.codeVerifier); + if (autoReferesh) { + setRefreshTimer(data.expires_in * 1000, async () => { + refreshToken(domain, clientId); + }); + } + + await activeStorage.removeItems( + StorageKeys.state, + StorageKeys.nonce, + StorageKeys.codeVerifier, + ); // Clear all url params const cleanUrl = (url: URL): URL => { diff --git a/lib/utils/generateAuthUrl.ts b/lib/utils/generateAuthUrl.ts index 1a349af..868b8b9 100644 --- a/lib/utils/generateAuthUrl.ts +++ b/lib/utils/generateAuthUrl.ts @@ -13,7 +13,12 @@ export const generateAuthUrl = async ( domain: string, type: IssuerRouteTypes = IssuerRouteTypes.login, options: LoginOptions, -): Promise<{ url: URL; state: string; nonce: string; codeChallenge: string }> => { +): Promise<{ + url: URL; + state: string; + nonce: string; + codeChallenge: string; +}> => { const authUrl = new URL(`${domain}/oauth2/auth`); const activeStorage = getInsecureStorage(); const searchParams: Record = { diff --git a/lib/utils/refreshTimer.ts b/lib/utils/refreshTimer.ts new file mode 100644 index 0000000..d5ca823 --- /dev/null +++ b/lib/utils/refreshTimer.ts @@ -0,0 +1,12 @@ +export let refreshTimer: number | undefined; + +export function setRefreshTimer(timer: number, callback: () => void) { + window.setTimeout(callback, timer); +} + +export function clearRefreshTimer() { + if (refreshTimer !== undefined) { + window.clearTimeout(refreshTimer); + refreshTimer = undefined; + } +} \ No newline at end of file diff --git a/lib/utils/token/refreshToken.ts b/lib/utils/token/refreshToken.ts index 1e060a7..6f98c4c 100644 --- a/lib/utils/token/refreshToken.ts +++ b/lib/utils/token/refreshToken.ts @@ -1,6 +1,7 @@ import { getActiveStorage } from "."; import { StorageKeys } from "../../sessionManager"; import { sanitizeUrl } from ".."; +import { clearRefreshTimer, setRefreshTimer } from "../refreshTimer"; /** * refreshes the token @@ -28,23 +29,25 @@ export const refreshToken = async ( return false; } - const refreshTokenValue = await storage.getSessionItem( + const refreshTokenValue = (await storage.getSessionItem( StorageKeys.refreshToken, - ) as string; - + )) as string; if (!refreshTokenValue) { console.error("No refresh token found"); return false; } + clearRefreshTimer(); + const response = await fetch(`${sanitizeUrl(domain)}/oauth2/token`, { method: "POST", headers: { - "Content-Type": "multipart/form-data", - "Kinde-SDK": "js-utils", + "Content-type": "application/x-www-form-urlencoded; charset=UTF-8", + "Cache-Control": "no-store", + Pragma: "no-cache", }, - body: JSON.stringify({ + body: new URLSearchParams({ refresh_token: refreshTokenValue, grant_type: "refresh_token", client_id: clientId, @@ -59,6 +62,9 @@ export const refreshToken = async ( const data = await response.json(); if (data.access_token) { + setRefreshTimer(data.expires_in * 1000, async () => { + refreshToken(domain, clientId); + }); await storage.setSessionItem(StorageKeys.accessToken, data.access_token); if (data.id_token) { await storage.setSessionItem(StorageKeys.idToken, data.id_token); From fe8e4c02f8554026dbafcd04259b9f8f3df2b2f4 Mon Sep 17 00:00:00 2001 From: Daniel Rivers Date: Tue, 19 Nov 2024 19:48:03 +0000 Subject: [PATCH 10/17] test: fix unit tests --- lib/main.test.ts | 4 + lib/utils/base64UrlEncode.test.ts | 12 +++ lib/utils/base64UrlEncode.ts | 24 +++--- lib/utils/exchangeAuthCode.test.ts | 70 +++++++++++++-- lib/utils/exchangeAuthCode.ts | 5 +- lib/utils/generateAuthUrl.test.ts | 6 +- lib/utils/generateAuthUrl.ts | 2 - lib/utils/refreshTimer.ts | 4 +- lib/utils/token/getDecodedToken.ts | 2 +- lib/utils/token/index.test.ts | 40 ++++++++- lib/utils/token/isAuthenticated.test.ts | 8 +- lib/utils/token/isAuthenticated.ts | 3 +- lib/utils/token/refreshToken.test.ts | 110 ++++++++++++++---------- lib/utils/token/refreshToken.ts | 64 ++++++++++---- 14 files changed, 254 insertions(+), 100 deletions(-) diff --git a/lib/main.test.ts b/lib/main.test.ts index 707068e..99f425d 100644 --- a/lib/main.test.ts +++ b/lib/main.test.ts @@ -54,6 +54,10 @@ describe("index exports", () => { "getActiveStorage", "hasActiveStorage", "clearActiveStorage", + "clearInsecureStorage", + "getInsecureStorage", + "hasInsecureStorage", + "setInsecureStorage", "getClaim", "getClaims", "getCurrentOrganization", diff --git a/lib/utils/base64UrlEncode.test.ts b/lib/utils/base64UrlEncode.test.ts index 8c9db9b..268c47a 100644 --- a/lib/utils/base64UrlEncode.test.ts +++ b/lib/utils/base64UrlEncode.test.ts @@ -36,4 +36,16 @@ describe("base64UrlEncode", () => { const result = base64UrlEncode(input); expect(result).toBe(expectedOutput); }); + + it("should encode when passed an ArrayBuffer", () => { + const buffer = new ArrayBuffer(8); + const view = new Uint8Array(buffer); + for (let i = 0; i < view.length; i++) { + view[i] = i + 1; + } + + const expectedOutput = "AQIDBAUGBwg"; + const result = base64UrlEncode(buffer); + expect(result).toBe(expectedOutput); + }); }); diff --git a/lib/utils/base64UrlEncode.ts b/lib/utils/base64UrlEncode.ts index 5f8e667..c9478fb 100644 --- a/lib/utils/base64UrlEncode.ts +++ b/lib/utils/base64UrlEncode.ts @@ -3,20 +3,18 @@ * @param str String to encode * @returns encoded string */ -export const base64UrlEncode = (str: string | ArrayBuffer): string => { - if (str instanceof ArrayBuffer) { - const numberArray = Array.from(new Uint8Array(str)); - return btoa(String.fromCharCode.apply(null, numberArray)) - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=+$/, ""); +export const base64UrlEncode = (input: string | ArrayBuffer): string => { + const toBase64Url = (str: string): string => + btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + + if (input instanceof ArrayBuffer) { + const uint8Array = new Uint8Array(input); + const binaryString = String.fromCharCode(...uint8Array); + return toBase64Url(binaryString); } const encoder = new TextEncoder(); - const uintArray = encoder.encode(str); - const charArray = Array.from(uintArray); - return btoa(String.fromCharCode.apply(null, charArray)) - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=+$/, ""); + const uint8Array = encoder.encode(input); + const binaryString = String.fromCharCode(...uint8Array); + return toBase64Url(binaryString); }; diff --git a/lib/utils/exchangeAuthCode.test.ts b/lib/utils/exchangeAuthCode.test.ts index 8bccc69..8537614 100644 --- a/lib/utils/exchangeAuthCode.test.ts +++ b/lib/utils/exchangeAuthCode.test.ts @@ -4,16 +4,22 @@ import { MemoryStorage, StorageKeys } from "../sessionManager"; import { setActiveStorage } from "./token"; import createFetchMock from "vitest-fetch-mock"; import { frameworkSettings } from "./exchangeAuthCode"; +import * as refreshTokenTimer from "./refreshTimer"; +import * as main from "../main"; const fetchMock = createFetchMock(vi); describe("exchangeAuthCode", () => { beforeEach(() => { fetchMock.enableMocks(); + vi.spyOn(refreshTokenTimer, "setRefreshTimer"); + vi.spyOn(main, "refreshToken"); + vi.useFakeTimers(); }); afterEach(() => { fetchMock.resetMocks(); + vi.useRealTimers(); }); it("missing state param", async () => { @@ -142,10 +148,14 @@ describe("exchangeAuthCode", () => { expect(url).toBe("http://test.kinde.com/oauth2/token"); expect(options).toMatchObject({ method: "POST", - headers: { - "Content-type": "application/x-www-form-urlencoded; charset=UTF-8", - }, }); + expect((options?.headers as Headers).get("Content-type")).toEqual( + "application/x-www-form-urlencoded; charset=UTF-8", + ); + expect((options?.headers as Headers).get("Cache-Control")).toEqual( + "no-store", + ); + expect((options?.headers as Headers).get("Pragma")).toEqual("no-cache"); }); it("set the framework and version on header", async () => { @@ -173,6 +183,7 @@ describe("exchangeAuthCode", () => { access_token: "access_token", refresh_token: "refresh_token", id_token: "id_token", + expires_in: 3600, }), ); @@ -188,11 +199,10 @@ describe("exchangeAuthCode", () => { expect(url).toBe("http://test.kinde.com/oauth2/token"); expect(options).toMatchObject({ method: "POST", - headers: { - "Content-type": "application/x-www-form-urlencoded; charset=UTF-8", - "Kinde-SDK": "Framework/Version", - }, }); + expect((options?.headers as Headers).get("Kinde-SDK")).toEqual( + "Framework/Version", + ); }); it("should handle token exchange failure", async () => { @@ -226,4 +236,50 @@ describe("exchangeAuthCode", () => { error: "Token exchange failed: 500 - error", }); }); + + it("should set the refresh timer", async () => { + const store = new MemoryStorage(); + setActiveStorage(store); + + const state = "state"; + + await store.setItems({ + [StorageKeys.state]: state, + }); + + frameworkSettings.framework = "Framework"; + frameworkSettings.frameworkVersion = "Version"; + + const input = "hello"; + + const urlParams = new URLSearchParams(); + urlParams.append("code", input); + 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", + expires_in: 3600, + }), + ); + + await exchangeAuthCode({ + urlParams, + domain: "http://test.kinde.com", + clientId: "test", + redirectURL: "http://test.kinde.com", + autoReferesh: true, + }); + + expect(refreshTokenTimer.setRefreshTimer).toHaveBeenCalledOnce(); + expect(refreshTokenTimer.setRefreshTimer).toHaveBeenCalledWith( + 3600, + expect.any(Function), + ); + vi.advanceTimersByTime(3600 * 1000); + expect(main.refreshToken).toHaveBeenCalledTimes(1); + }); }); diff --git a/lib/utils/exchangeAuthCode.ts b/lib/utils/exchangeAuthCode.ts index 35d5b13..2302f77 100644 --- a/lib/utils/exchangeAuthCode.ts +++ b/lib/utils/exchangeAuthCode.ts @@ -77,7 +77,6 @@ export const exchangeAuthCode = async ({ StorageKeys.codeVerifier, )) as string; - const headers: { "Content-type": string; "Cache-Control": string; @@ -115,7 +114,7 @@ export const exchangeAuthCode = async ({ error: `Token exchange failed: ${response.status} - ${errorText}`, }; } - clearRefreshTimer() + clearRefreshTimer(); const data: { access_token: string; @@ -132,7 +131,7 @@ export const exchangeAuthCode = async ({ }); if (autoReferesh) { - setRefreshTimer(data.expires_in * 1000, async () => { + setRefreshTimer(data.expires_in, async () => { refreshToken(domain, clientId); }); } diff --git a/lib/utils/generateAuthUrl.test.ts b/lib/utils/generateAuthUrl.test.ts index 9c2179e..0d70abc 100644 --- a/lib/utils/generateAuthUrl.test.ts +++ b/lib/utils/generateAuthUrl.test.ts @@ -32,7 +32,7 @@ describe("generateAuthUrl", () => { expect(nonce!.length).toBe(16); result.url.searchParams.delete("nonce"); const codeChallenge = result.url.searchParams.get("code_challenge"); - expect(codeChallenge!.length).toBeGreaterThan(43); + expect(codeChallenge!.length).toBeGreaterThanOrEqual(27); result.url.searchParams.delete("code_challenge"); expect(result.url.toString()).toBe(expectedUrl); }); @@ -88,7 +88,7 @@ describe("generateAuthUrl", () => { result.url.searchParams.delete("nonce"); const codeChallenge = result.url.searchParams.get("code_challenge"); - expect(codeChallenge!.length).toBeGreaterThan(43); + expect(codeChallenge!.length).toBeGreaterThanOrEqual(27); result.url.searchParams.delete("code_challenge"); expect(result.url.toString()).toBe(expectedUrl); @@ -117,7 +117,7 @@ describe("generateAuthUrl", () => { expect(state).not.toBeNull(); expect(state!.length).toBe(32); const codeChallenge = result.url.searchParams.get("code_challenge"); - expect(codeChallenge!.length).toBeGreaterThan(43); + expect(codeChallenge!.length).toBeGreaterThanOrEqual(27); result.url.searchParams.delete("code_challenge"); result.url.searchParams.delete("nonce"); result.url.searchParams.delete("state"); diff --git a/lib/utils/generateAuthUrl.ts b/lib/utils/generateAuthUrl.ts index 868b8b9..d5e9626 100644 --- a/lib/utils/generateAuthUrl.ts +++ b/lib/utils/generateAuthUrl.ts @@ -75,8 +75,6 @@ export async function generatePKCEPair(): Promise<{ const codeVerifier = generateRandomString(52); const data = new TextEncoder().encode(codeVerifier); const hashed = await crypto.subtle.digest("SHA-256", data); - // const hashArray = Array.from(new Uint8Array(hashed)); - // const hashString = hashArray.map((b) => String.fromCharCode(b)).join(""); const codeChallenge = base64UrlEncode(hashed); return { codeVerifier, codeChallenge }; } diff --git a/lib/utils/refreshTimer.ts b/lib/utils/refreshTimer.ts index d5ca823..c63f768 100644 --- a/lib/utils/refreshTimer.ts +++ b/lib/utils/refreshTimer.ts @@ -1,7 +1,7 @@ export let refreshTimer: number | undefined; export function setRefreshTimer(timer: number, callback: () => void) { - window.setTimeout(callback, timer); + refreshTimer = window.setTimeout(callback, timer * 1000 - 10000); } export function clearRefreshTimer() { @@ -9,4 +9,4 @@ export function clearRefreshTimer() { window.clearTimeout(refreshTimer); refreshTimer = undefined; } -} \ No newline at end of file +} diff --git a/lib/utils/token/getDecodedToken.ts b/lib/utils/token/getDecodedToken.ts index 8d3740d..193ce5f 100644 --- a/lib/utils/token/getDecodedToken.ts +++ b/lib/utils/token/getDecodedToken.ts @@ -31,7 +31,7 @@ export const getDecodedToken = async < const decodedToken = jwtDecoder(token); if (!decodedToken) { - console.log("No decoded token found"); + console.warn("No decoded token found"); } return decodedToken; diff --git a/lib/utils/token/index.test.ts b/lib/utils/token/index.test.ts index f05289a..78de567 100644 --- a/lib/utils/token/index.test.ts +++ b/lib/utils/token/index.test.ts @@ -1,12 +1,22 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, beforeEach } from "vitest"; import { MemoryStorage } from "../../sessionManager"; import { getActiveStorage, hasActiveStorage, setActiveStorage, clearActiveStorage, + setInsecureStorage, + hasInsecureStorage, + clearInsecureStorage, + getInsecureStorage, } from "."; + describe("token index", () => { + beforeEach(() => { + clearActiveStorage(); + clearInsecureStorage(); + }); + it("hasActiveStorage returns true when storage is set", async () => { const storage = new MemoryStorage(); setActiveStorage(storage); @@ -28,4 +38,32 @@ describe("token index", () => { setActiveStorage(storage); expect(getActiveStorage()).toBe(storage); }); + + it("hasInsecureStorage returns true when insecure storage is set", async () => { + const storage = new MemoryStorage(); + setInsecureStorage(storage); + expect(hasInsecureStorage()).toStrictEqual(true); + }); + + it("hasInsecureStorage returns false when insecure storage is cleared", async () => { + clearInsecureStorage(); + expect(hasInsecureStorage()).toStrictEqual(false); + }); + + it("getInsecureStorage returns null when no insecure storage is set", async () => { + clearInsecureStorage(); + clearActiveStorage(); + expect(getInsecureStorage()).toBeNull(); + }); + + it("getInsecureStorage returns active storage when no insecure storage is set", async () => { + clearInsecureStorage(); + expect(getInsecureStorage()).toBeNull(); + }); + + it("getInsecureStorage returns storage instance when set", async () => { + const storage = new MemoryStorage(); + setInsecureStorage(storage); + expect(getInsecureStorage()).toBe(storage); + }); }); diff --git a/lib/utils/token/isAuthenticated.test.ts b/lib/utils/token/isAuthenticated.test.ts index 8716218..6534660 100644 --- a/lib/utils/token/isAuthenticated.test.ts +++ b/lib/utils/token/isAuthenticated.test.ts @@ -51,7 +51,7 @@ describe("isAuthenticated", () => { }); const mockRefreshToken = vi .spyOn(tokenUtils, "refreshToken") - .mockResolvedValue(true); + .mockResolvedValue({ success: true }); const result = await isAuthenticated({ useRefreshToken: true, @@ -67,7 +67,7 @@ describe("isAuthenticated", () => { vi.spyOn(tokenUtils, "getDecodedToken").mockResolvedValue({ exp: mockCurrentTime - 3600, }); - vi.spyOn(tokenUtils, "refreshToken").mockResolvedValue(false); + vi.spyOn(tokenUtils, "refreshToken").mockResolvedValue({ success: false }); const result = await isAuthenticated({ useRefreshToken: true, @@ -96,9 +96,9 @@ describe("isAuthenticated", () => { vi.spyOn(tokenUtils, "getDecodedToken").mockResolvedValue({ // Missing 'exp' field }); - + const result = await isAuthenticated(); - + expect(result).toBe(false); }); }); diff --git a/lib/utils/token/isAuthenticated.ts b/lib/utils/token/isAuthenticated.ts index b3fc8ef..e17bf57 100644 --- a/lib/utils/token/isAuthenticated.ts +++ b/lib/utils/token/isAuthenticated.ts @@ -36,7 +36,8 @@ export const isAuthenticated = async ( const isExpired = token.exp < Math.floor(Date.now() / 1000); if (isExpired && props?.useRefreshToken) { - return refreshToken(props.domain, props.clientId); + const refreshResult = await refreshToken(props.domain, props.clientId); + return refreshResult.success; } return !isExpired; } catch (error) { diff --git a/lib/utils/token/refreshToken.test.ts b/lib/utils/token/refreshToken.test.ts index 9579b39..5689b4e 100644 --- a/lib/utils/token/refreshToken.test.ts +++ b/lib/utils/token/refreshToken.test.ts @@ -1,89 +1,104 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { SessionManager, StorageKeys } from "../../sessionManager"; +import { MemoryStorage, StorageKeys } from "../../sessionManager"; import * as tokenUtils from "."; describe("refreshToken", () => { const mockDomain = "https://example.com"; const mockClientId = "test-client-id"; const mockRefreshTokenValue = "mock-refresh-token"; - const mockStorage: SessionManager = { - getSessionItem: vi.fn(), - setSessionItem: vi.fn(), - removeSessionItem: vi.fn(), - destroySession: vi.fn(), - setItems: vi.fn(), - removeItems: vi.fn(), - }; + const memoryStorage = new MemoryStorage(); + beforeEach(() => { vi.resetAllMocks(); vi.spyOn(tokenUtils, "getDecodedToken").mockResolvedValue(null); - vi.spyOn(tokenUtils, "getActiveStorage").mockResolvedValue(mockStorage); - // vi.spyOn(Utils, 'sanitizeUrl').mockImplementation((url) => url); + vi.spyOn(memoryStorage, "setSessionItem"); + tokenUtils.setActiveStorage(memoryStorage); global.fetch = vi.fn(); vi.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { + memoryStorage.destroySession(); vi.restoreAllMocks(); }); it("should return false if domain is not provided", async () => { const result = await tokenUtils.refreshToken("", mockClientId); - expect(result).toBe(false); - expect(console.error).toHaveBeenCalledWith( - "Domain is required for token refresh", - ); + expect(result).toStrictEqual({ + error: "Domain is required for token refresh", + success: false, + }); }); it("should return false if clientId is not provided", async () => { const result = await tokenUtils.refreshToken(mockDomain, ""); - expect(result).toBe(false); - expect(console.error).toHaveBeenCalledWith( - "Client ID is required for token refresh", - ); + expect(result).toStrictEqual({ + error: "Client ID is required for token refresh", + success: false, + }); + }); + + it("no active storage should error", async () => { + tokenUtils.clearActiveStorage(); + const result = await tokenUtils.refreshToken(mockDomain, mockClientId); + expect(result).toStrictEqual({ + error: "No active storage found", + success: false, + }); }); it("should return false if no refresh token is found", async () => { - // mockStorage.getSessionItem.mockResolvedValue(null); const result = await tokenUtils.refreshToken(mockDomain, mockClientId); - expect(result).toBe(false); - expect(console.error).toHaveBeenCalledWith("No refresh token found"); + expect(result).toStrictEqual({ + error: "No refresh token found", + success: false, + }); }); it("should return false if the fetch request fails", async () => { - mockStorage.getSessionItem = vi - .fn() - .mockResolvedValue(mockRefreshTokenValue); + await memoryStorage.setSessionItem( + StorageKeys.refreshToken, + mockRefreshTokenValue, + ); vi.mocked(global.fetch).mockRejectedValue(new Error("Network error")); + const result = await tokenUtils.refreshToken(mockDomain, mockClientId); - expect(result).toBe(false); - expect(console.error).toHaveBeenCalledWith( - "Error refreshing token:", - expect.any(Error), - ); + expect(result).toStrictEqual({ + error: "Error refreshing token: Error: Network error", + success: false, + }); }); it("should return false if the response is not ok", async () => { - mockStorage.getSessionItem = vi - .fn() - .mockResolvedValue(mockRefreshTokenValue); + await memoryStorage.setSessionItem( + StorageKeys.refreshToken, + mockRefreshTokenValue, + ); vi.mocked(global.fetch).mockResolvedValue({ ok: false } as Response); + const result = await tokenUtils.refreshToken(mockDomain, mockClientId); - expect(result).toBe(false); - expect(console.error).toHaveBeenCalledWith("Failed to refresh token"); + expect(result).toStrictEqual({ + error: "Failed to refresh token", + success: false, + }); }); it("should return false if the response does not contain an access token", async () => { - mockStorage.getSessionItem = vi - .fn() - .mockResolvedValue(mockRefreshTokenValue); + await memoryStorage.setSessionItem( + StorageKeys.refreshToken, + mockRefreshTokenValue, + ); vi.mocked(global.fetch).mockResolvedValue({ ok: true, json: () => Promise.resolve({}), } as Response); + const result = await tokenUtils.refreshToken(mockDomain, mockClientId); - expect(result).toBe(false); + expect(result).toStrictEqual({ + error: "No access token recieved", + success: false, + }); }); it("should return true and update tokens if the refresh is successful", async () => { @@ -92,7 +107,7 @@ describe("refreshToken", () => { id_token: "new-id-token", refresh_token: "new-refresh-token", }; - mockStorage.getSessionItem = vi + memoryStorage.getSessionItem = vi .fn() .mockResolvedValue(mockRefreshTokenValue); vi.mocked(global.fetch).mockResolvedValue({ @@ -102,23 +117,28 @@ describe("refreshToken", () => { const result = await tokenUtils.refreshToken(mockDomain, mockClientId); - expect(result).toBe(true); - expect(mockStorage.setSessionItem).toHaveBeenCalledWith( + expect(result).toStrictEqual({ + success: true, + accessToken: "new-access-token", + idToken: "new-id-token", + refreshToken: "new-refresh-token", + }); + expect(memoryStorage.setSessionItem).toHaveBeenCalledWith( StorageKeys.accessToken, "new-access-token", ); - expect(mockStorage.setSessionItem).toHaveBeenCalledWith( + expect(memoryStorage.setSessionItem).toHaveBeenCalledWith( StorageKeys.idToken, "new-id-token", ); - expect(mockStorage.setSessionItem).toHaveBeenCalledWith( + expect(memoryStorage.setSessionItem).toHaveBeenCalledWith( StorageKeys.refreshToken, "new-refresh-token", ); }); it("should use sanitizeUrl for the domain", async () => { - mockStorage.getSessionItem = vi + memoryStorage.getSessionItem = vi .fn() .mockResolvedValue(mockRefreshTokenValue); vi.mocked(global.fetch).mockResolvedValue({ diff --git a/lib/utils/token/refreshToken.ts b/lib/utils/token/refreshToken.ts index 6f98c4c..59813fb 100644 --- a/lib/utils/token/refreshToken.ts +++ b/lib/utils/token/refreshToken.ts @@ -3,6 +3,14 @@ import { StorageKeys } from "../../sessionManager"; import { sanitizeUrl } from ".."; import { clearRefreshTimer, setRefreshTimer } from "../refreshTimer"; +interface RefreshTokenResult { + success: boolean; + error?: string; + [StorageKeys.accessToken]?: string; + [StorageKeys.idToken]?: string; + [StorageKeys.refreshToken]?: string; +} + /** * refreshes the token * @returns { Promise } @@ -10,23 +18,29 @@ import { clearRefreshTimer, setRefreshTimer } from "../refreshTimer"; export const refreshToken = async ( domain: string, clientId: string, -): Promise => { +): Promise => { try { if (!domain) { - console.error("Domain is required for token refresh"); - return false; + return { + success: false, + error: "Domain is required for token refresh", + }; } if (!clientId) { - console.error("Client ID is required for token refresh"); - return false; + return { + success: false, + error: "Client ID is required for token refresh", + }; } - const storage = await getActiveStorage(); + const storage = getActiveStorage(); if (!storage) { - console.error("No active storage found"); - return false; + return { + success: false, + error: "No active storage found", + }; } const refreshTokenValue = (await storage.getSessionItem( @@ -34,8 +48,10 @@ export const refreshToken = async ( )) as string; if (!refreshTokenValue) { - console.error("No refresh token found"); - return false; + return { + success: false, + error: "No refresh token found", + }; } clearRefreshTimer(); @@ -47,7 +63,7 @@ export const refreshToken = async ( "Cache-Control": "no-store", Pragma: "no-cache", }, - body: new URLSearchParams({ + body: new URLSearchParams({ refresh_token: refreshTokenValue, grant_type: "refresh_token", client_id: clientId, @@ -55,14 +71,16 @@ export const refreshToken = async ( }); if (!response.ok) { - console.error("Failed to refresh token"); - return false; + return { + success: false, + error: "Failed to refresh token", + }; } const data = await response.json(); if (data.access_token) { - setRefreshTimer(data.expires_in * 1000, async () => { + setRefreshTimer(data.expires_in, async () => { refreshToken(domain, clientId); }); await storage.setSessionItem(StorageKeys.accessToken, data.access_token); @@ -75,12 +93,22 @@ export const refreshToken = async ( data.refresh_token, ); } - return true; + return { + success: true, + [StorageKeys.accessToken]: data.access_token, + [StorageKeys.idToken]: data.id_token, + [StorageKeys.refreshToken]: data.refresh_token, + }; } - return false; + return { + success: false, + error: `No access token recieved`, + }; } catch (error) { - console.error("Error refreshing token:", error); - return false; + return { + success: false, + error: `Error refreshing token: ${error}`, + }; } }; From c89a6cede6c2aaadd0e883dea780fad321fb7a75 Mon Sep 17 00:00:00 2001 From: Daniel Rivers Date: Tue, 19 Nov 2024 19:49:31 +0000 Subject: [PATCH 11/17] chore: lint --- lib/utils/token/refreshToken.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/utils/token/refreshToken.test.ts b/lib/utils/token/refreshToken.test.ts index 5689b4e..7dde130 100644 --- a/lib/utils/token/refreshToken.test.ts +++ b/lib/utils/token/refreshToken.test.ts @@ -8,7 +8,6 @@ describe("refreshToken", () => { const mockRefreshTokenValue = "mock-refresh-token"; const memoryStorage = new MemoryStorage(); - beforeEach(() => { vi.resetAllMocks(); vi.spyOn(tokenUtils, "getDecodedToken").mockResolvedValue(null); From 014461ec3409e7f9bc02be1bdcbcf04e0b32600b Mon Sep 17 00:00:00 2001 From: Daniel Rivers Date: Tue, 19 Nov 2024 19:53:48 +0000 Subject: [PATCH 12/17] fix: done make refreshTimer exported --- lib/utils/refreshTimer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/utils/refreshTimer.ts b/lib/utils/refreshTimer.ts index c63f768..ebfec5d 100644 --- a/lib/utils/refreshTimer.ts +++ b/lib/utils/refreshTimer.ts @@ -1,4 +1,4 @@ -export let refreshTimer: number | undefined; +let refreshTimer: number | undefined; export function setRefreshTimer(timer: number, callback: () => void) { refreshTimer = window.setTimeout(callback, timer * 1000 - 10000); From 5c2089f43481b19c0009344d647157a78e2263e1 Mon Sep 17 00:00:00 2001 From: Daniel Rivers Date: Tue, 19 Nov 2024 20:24:26 +0000 Subject: [PATCH 13/17] test: expand refresh token tests --- lib/utils/refreshTimer.test.ts | 33 +++++++++++++++++++++++++++++++++ lib/utils/refreshTimer.ts | 7 +++++++ 2 files changed, 40 insertions(+) create mode 100644 lib/utils/refreshTimer.test.ts diff --git a/lib/utils/refreshTimer.test.ts b/lib/utils/refreshTimer.test.ts new file mode 100644 index 0000000..c56506f --- /dev/null +++ b/lib/utils/refreshTimer.test.ts @@ -0,0 +1,33 @@ +import * as RefreshTimer from "./refreshTimer"; +import { beforeAll, describe, expect, it, vi } from "vitest"; + +describe("refreshTimer", () => { + beforeAll(() => { + vi.spyOn(window, "setTimeout"); + vi.spyOn(window, "clearTimeout"); + vi.spyOn(RefreshTimer, 'clearRefreshTimer'); + }) + + it("set timer and not call callback instantly", () => { + const callback = vi.fn(); + RefreshTimer.setRefreshTimer(10, callback); + expect(callback).not.toHaveBeenCalled(); + }) + + it("error when timeout is negative", () => { + const callback = vi.fn(); + expect(()=>RefreshTimer.setRefreshTimer(-10, callback)).toThrowError("Timer duration must be positive"); + }) + + it("should throw error when window is undefined", () => { + const originalWindow = global.window; + // Temporarily delete the window object + delete global.window; + + const callback = vi.fn(); + expect(() => RefreshTimer.setRefreshTimer(10, callback)).toThrowError("setRefreshTimer requires a browser environment"); + + // Restore the window object + global.window = originalWindow; + }); +}); diff --git a/lib/utils/refreshTimer.ts b/lib/utils/refreshTimer.ts index ebfec5d..6e74240 100644 --- a/lib/utils/refreshTimer.ts +++ b/lib/utils/refreshTimer.ts @@ -1,6 +1,13 @@ let refreshTimer: number | undefined; export function setRefreshTimer(timer: number, callback: () => void) { + clearRefreshTimer(); + if (typeof window === 'undefined') { + throw new Error('setRefreshTimer requires a browser environment'); + } + if (timer <= 0) { + throw new Error('Timer duration must be positive'); + } refreshTimer = window.setTimeout(callback, timer * 1000 - 10000); } From 6012806ddaf5cf26515a30b2433203c9dcc0d4a4 Mon Sep 17 00:00:00 2001 From: Daniel Rivers Date: Tue, 19 Nov 2024 20:38:00 +0000 Subject: [PATCH 14/17] fix: correct incorrect spelling --- lib/utils/exchangeAuthCode.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/utils/exchangeAuthCode.ts b/lib/utils/exchangeAuthCode.ts index 2302f77..ab62587 100644 --- a/lib/utils/exchangeAuthCode.ts +++ b/lib/utils/exchangeAuthCode.ts @@ -19,7 +19,7 @@ interface ExchangeAuthCodeParams { domain: string; clientId: string; redirectURL: string; - autoReferesh?: boolean; + autoRefresh?: boolean; } interface ExchangeAuthCodeResult { @@ -35,7 +35,7 @@ export const exchangeAuthCode = async ({ domain, clientId, redirectURL, - autoReferesh = false, + autoRefresh = false, }: ExchangeAuthCodeParams): Promise => { const state = urlParams.get("state"); const code = urlParams.get("code"); @@ -130,7 +130,7 @@ export const exchangeAuthCode = async ({ [StorageKeys.refreshToken]: data.refresh_token, }); - if (autoReferesh) { + if (autoRefresh) { setRefreshTimer(data.expires_in, async () => { refreshToken(domain, clientId); }); From e900979ee503a65f307658462e6a5e99a1373a31 Mon Sep 17 00:00:00 2001 From: Daniel Rivers Date: Tue, 19 Nov 2024 20:48:23 +0000 Subject: [PATCH 15/17] chore: lint --- lib/utils/refreshTimer.test.ts | 16 ++++++++++------ lib/utils/refreshTimer.ts | 6 +++--- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/lib/utils/refreshTimer.test.ts b/lib/utils/refreshTimer.test.ts index c56506f..9557129 100644 --- a/lib/utils/refreshTimer.test.ts +++ b/lib/utils/refreshTimer.test.ts @@ -5,19 +5,21 @@ describe("refreshTimer", () => { beforeAll(() => { vi.spyOn(window, "setTimeout"); vi.spyOn(window, "clearTimeout"); - vi.spyOn(RefreshTimer, 'clearRefreshTimer'); - }) + vi.spyOn(RefreshTimer, "clearRefreshTimer"); + }); it("set timer and not call callback instantly", () => { const callback = vi.fn(); RefreshTimer.setRefreshTimer(10, callback); expect(callback).not.toHaveBeenCalled(); - }) + }); it("error when timeout is negative", () => { const callback = vi.fn(); - expect(()=>RefreshTimer.setRefreshTimer(-10, callback)).toThrowError("Timer duration must be positive"); - }) + expect(() => RefreshTimer.setRefreshTimer(-10, callback)).toThrowError( + "Timer duration must be positive", + ); + }); it("should throw error when window is undefined", () => { const originalWindow = global.window; @@ -25,7 +27,9 @@ describe("refreshTimer", () => { delete global.window; const callback = vi.fn(); - expect(() => RefreshTimer.setRefreshTimer(10, callback)).toThrowError("setRefreshTimer requires a browser environment"); + expect(() => RefreshTimer.setRefreshTimer(10, callback)).toThrowError( + "setRefreshTimer requires a browser environment", + ); // Restore the window object global.window = originalWindow; diff --git a/lib/utils/refreshTimer.ts b/lib/utils/refreshTimer.ts index 6e74240..a895490 100644 --- a/lib/utils/refreshTimer.ts +++ b/lib/utils/refreshTimer.ts @@ -2,11 +2,11 @@ let refreshTimer: number | undefined; export function setRefreshTimer(timer: number, callback: () => void) { clearRefreshTimer(); - if (typeof window === 'undefined') { - throw new Error('setRefreshTimer requires a browser environment'); + if (typeof window === "undefined") { + throw new Error("setRefreshTimer requires a browser environment"); } if (timer <= 0) { - throw new Error('Timer duration must be positive'); + throw new Error("Timer duration must be positive"); } refreshTimer = window.setTimeout(callback, timer * 1000 - 10000); } From 8de8eb39bdccf0fff820e22777f6dde6d627f9a5 Mon Sep 17 00:00:00 2001 From: Daniel Rivers Date: Tue, 19 Nov 2024 20:50:27 +0000 Subject: [PATCH 16/17] fix: build error --- lib/utils/exchangeAuthCode.test.ts | 2 +- lib/utils/refreshTimer.test.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/utils/exchangeAuthCode.test.ts b/lib/utils/exchangeAuthCode.test.ts index 8537614..bae2f15 100644 --- a/lib/utils/exchangeAuthCode.test.ts +++ b/lib/utils/exchangeAuthCode.test.ts @@ -271,7 +271,7 @@ describe("exchangeAuthCode", () => { domain: "http://test.kinde.com", clientId: "test", redirectURL: "http://test.kinde.com", - autoReferesh: true, + autoRefresh: true, }); expect(refreshTokenTimer.setRefreshTimer).toHaveBeenCalledOnce(); diff --git a/lib/utils/refreshTimer.test.ts b/lib/utils/refreshTimer.test.ts index 9557129..a6debcb 100644 --- a/lib/utils/refreshTimer.test.ts +++ b/lib/utils/refreshTimer.test.ts @@ -24,6 +24,7 @@ describe("refreshTimer", () => { it("should throw error when window is undefined", () => { const originalWindow = global.window; // Temporarily delete the window object + //@ts-ignore delete global.window; const callback = vi.fn(); From 4b0efcfe7b54f64961dab6f2e4eead350305e9d3 Mon Sep 17 00:00:00 2001 From: Daniel Rivers Date: Tue, 19 Nov 2024 20:54:31 +0000 Subject: [PATCH 17/17] chore: allow ts-ignore in test --- lib/utils/refreshTimer.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/utils/refreshTimer.test.ts b/lib/utils/refreshTimer.test.ts index a6debcb..abc52d2 100644 --- a/lib/utils/refreshTimer.test.ts +++ b/lib/utils/refreshTimer.test.ts @@ -24,7 +24,8 @@ describe("refreshTimer", () => { it("should throw error when window is undefined", () => { const originalWindow = global.window; // Temporarily delete the window object - //@ts-ignore + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore delete global.window; const callback = vi.fn();