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}`, + }; } };