diff --git a/lib/main.test.ts b/lib/main.test.ts index 6338656..9a5a807 100644 --- a/lib/main.test.ts +++ b/lib/main.test.ts @@ -47,6 +47,7 @@ describe("index exports", () => { "mapLoginMethodParamsForUrl", "sanitizeUrl", "exchangeAuthCode", + "getCookieOptions", "isAuthenticated", "isTokenExpired", "refreshToken", diff --git a/lib/main.ts b/lib/main.ts index 99381da..03a4aaa 100644 --- a/lib/main.ts +++ b/lib/main.ts @@ -24,7 +24,9 @@ export { sessionManagerActivityProxy, isClient, isServer, + getCookieOptions, } from "./utils"; +export type { CookieEnv, CookieOptions, CookieOptionValue } from "./utils"; export { getClaim, diff --git a/lib/utils/exchangeAuthCode.test.ts b/lib/utils/exchangeAuthCode.test.ts index 523f769..0f156df 100644 --- a/lib/utils/exchangeAuthCode.test.ts +++ b/lib/utils/exchangeAuthCode.test.ts @@ -6,12 +6,19 @@ import { clearActiveStorage, clearInsecureStorage, } 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); +const fetchMock = vi.fn(); + +const jsonResponse = (data: unknown, init?: ResponseInit) => + new Response(JSON.stringify(data), { + headers: { + "Content-Type": "application/json", + }, + ...init, + }); describe("exchangeAuthCode", () => { const mockStorage = { @@ -26,7 +33,8 @@ describe("exchangeAuthCode", () => { }; beforeEach(() => { - fetchMock.enableMocks(); + fetchMock.mockReset(); + vi.stubGlobal("fetch", fetchMock); vi.spyOn(refreshTokenTimer, "setRefreshTimer"); vi.spyOn(main, "refreshToken"); vi.useFakeTimers(); @@ -35,7 +43,8 @@ describe("exchangeAuthCode", () => { }); afterEach(() => { - fetchMock.resetMocks(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); vi.useRealTimers(); }); @@ -134,8 +143,8 @@ describe("exchangeAuthCode", () => { urlParams.append("state", state); urlParams.append("client_id", "test"); - fetchMock.mockResponseOnce( - JSON.stringify({ + fetchMock.mockResolvedValueOnce( + jsonResponse({ access_token: "access_token", refresh_token: "refresh_token", id_token: "id_token", @@ -191,8 +200,8 @@ describe("exchangeAuthCode", () => { urlParams.append("state", state); urlParams.append("client_id", "test"); - fetchMock.mockResponseOnce( - JSON.stringify({ + fetchMock.mockResolvedValueOnce( + jsonResponse({ access_token: "access_token", refresh_token: "refresh_token", id_token: "id_token", @@ -247,8 +256,8 @@ describe("exchangeAuthCode", () => { urlParams.append("state", state); urlParams.append("client_id", "test"); - fetchMock.mockResponseOnce( - JSON.stringify({ + fetchMock.mockResolvedValueOnce( + jsonResponse({ access_token: "access_token", refresh_token: "refresh_token", id_token: "id_token", @@ -292,7 +301,12 @@ describe("exchangeAuthCode", () => { urlParams.append("state", state); urlParams.append("client_id", "test"); - fetchMock.mockOnce({ status: 500, ok: false, body: "error" }); + fetchMock.mockResolvedValueOnce( + new Response("error", { + status: 500, + statusText: "Internal Server Error", + }), + ); const result = await exchangeAuthCode({ urlParams, @@ -328,8 +342,8 @@ describe("exchangeAuthCode", () => { urlParams.append("state", state); urlParams.append("client_id", "test"); - fetchMock.mockResponseOnce( - JSON.stringify({ + fetchMock.mockResolvedValueOnce( + jsonResponse({ access_token: "access_token", refresh_token: "refresh_token", id_token: "id_token", @@ -444,7 +458,7 @@ describe("exchangeAuthCode", () => { if (key === StorageKeys.codeVerifier) return "verifier"; return null; }); - fetchMock.mockRejectOnce(new Error("Fetch failed")); + fetchMock.mockRejectedValueOnce(new Error("Fetch failed")); await expect( exchangeAuthCode({ @@ -472,10 +486,7 @@ describe("exchangeAuthCode", () => { if (key === StorageKeys.codeVerifier) return "verifier"; return null; }); - vi.mocked(global.fetch).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({}), - } as Response); + fetchMock.mockResolvedValueOnce(jsonResponse({})); const result = await exchangeAuthCode({ urlParams, @@ -503,10 +514,7 @@ describe("exchangeAuthCode", () => { if (key === StorageKeys.codeVerifier) return "verifier"; return null; }); - vi.mocked(global.fetch).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({}), - } as Response); + fetchMock.mockResolvedValueOnce(jsonResponse({})); await exchangeAuthCode({ urlParams, @@ -516,14 +524,14 @@ describe("exchangeAuthCode", () => { clientSecret: "secret", }); - expect(global.fetch).toHaveBeenCalledWith( + expect(fetchMock).toHaveBeenCalledWith( "test.com/oauth2/token", expect.objectContaining({ body: expect.any(URLSearchParams), }), ); - const fetchCall = vi.mocked(global.fetch).mock.calls[0]; + const fetchCall = fetchMock.mock.calls[0]; const actualBody = fetchCall[1]?.body as URLSearchParams; expect(actualBody.get("client_id")).toBe("test"); @@ -552,15 +560,13 @@ describe("exchangeAuthCode", () => { return null; }); - vi.mocked(global.fetch).mockResolvedValue({ - ok: true, - json: () => - Promise.resolve({ - access_token: "access", - id_token: "id", - refresh_token: "refresh", - }), - } as Response); + fetchMock.mockResolvedValue( + jsonResponse({ + access_token: "access", + id_token: "id", + refresh_token: "refresh", + }), + ); const result = await exchangeAuthCode({ urlParams, diff --git a/lib/utils/getCookieOptions.test.ts b/lib/utils/getCookieOptions.test.ts new file mode 100644 index 0000000..d855be8 --- /dev/null +++ b/lib/utils/getCookieOptions.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, vi } from "vitest"; + +import { + getCookieOptions, + removeTrailingSlash, + TWENTY_NINE_DAYS, + MAX_COOKIE_LENGTH, +} from "./getCookieOptions"; + +describe("getCookieOptions", () => { + it("returns the default configuration when env is provided", () => { + const result = getCookieOptions(undefined, { + NODE_ENV: "production", + KINDE_COOKIE_DOMAIN: "example.com/", + }); + + expect(result).toMatchObject({ + maxAge: TWENTY_NINE_DAYS, + domain: "example.com", + maxCookieLength: MAX_COOKIE_LENGTH, + sameSite: "lax", + httpOnly: true, + path: "/", + secure: true, + }); + }); + + it("allows consumers to override default options", () => { + const result = getCookieOptions( + { + secure: false, + sameSite: "none", + path: "/custom", + maxAge: 60, + customOption: "value", + }, + { + NODE_ENV: "production", + KINDE_COOKIE_DOMAIN: "example.com", + }, + ); + + expect(result.secure).toBe(false); + expect(result.sameSite).toBe("none"); + expect(result.path).toBe("/custom"); + expect(result.maxAge).toBe(60); + expect(result.customOption).toBe("value"); + }); + + it("falls back to runtime environment variables when env param is omitted", () => { + const previousNodeEnv = process.env.NODE_ENV; + const previousCookieDomain = process.env.KINDE_COOKIE_DOMAIN; + + process.env.NODE_ENV = "production"; + process.env.KINDE_COOKIE_DOMAIN = "runtime-domain.io/"; + + const result = getCookieOptions(); + + expect(result.domain).toBe("runtime-domain.io"); + expect(result.secure).toBe(true); + + if (previousNodeEnv === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = previousNodeEnv; + } + + if (previousCookieDomain === undefined) { + delete process.env.KINDE_COOKIE_DOMAIN; + } else { + process.env.KINDE_COOKIE_DOMAIN = previousCookieDomain; + } + }); + + it("warns when NODE_ENV is missing and secure option is not provided", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const result = getCookieOptions({}, {}); + + expect(result.secure).toBe(false); + expect(warnSpy).toHaveBeenCalledWith( + "getCookieOptions: NODE_ENV not set; defaulting secure cookie flag to false. Provide env or override secure to suppress this warning.", + ); + + warnSpy.mockRestore(); + }); + + it("warns when KINDE_COOKIE_DOMAIN resolves to an empty string", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const result = getCookieOptions( + {}, + { NODE_ENV: "development", KINDE_COOKIE_DOMAIN: " " }, + ); + + expect(result.domain).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith( + "getCookieOptions: KINDE_COOKIE_DOMAIN is empty after trimming and will be ignored.", + ); + + warnSpy.mockRestore(); + }); +}); + +describe("removeTrailingSlash", () => { + it("removes trailing slashes and trims whitespace", () => { + expect(removeTrailingSlash("example.com/")).toBe("example.com"); + expect(removeTrailingSlash(" example.com/ ")).toBe("example.com"); + }); + + it("returns the original string when there is no trailing slash", () => { + expect(removeTrailingSlash("example.com")).toBe("example.com"); + }); + + it("returns undefined for nullish values", () => { + expect(removeTrailingSlash(undefined)).toBeUndefined(); + expect(removeTrailingSlash(null)).toBeUndefined(); + }); + + it("returns undefined for whitespace-only strings", () => { + expect(removeTrailingSlash(" ")).toBeUndefined(); + }); +}); diff --git a/lib/utils/getCookieOptions.ts b/lib/utils/getCookieOptions.ts new file mode 100644 index 0000000..d6fed83 --- /dev/null +++ b/lib/utils/getCookieOptions.ts @@ -0,0 +1,94 @@ +export interface CookieEnv { + NODE_ENV?: string; + KINDE_COOKIE_DOMAIN?: string; + [key: string]: string | undefined; +} + +export type CookieOptionValue = string | number | boolean | undefined | null; + +export interface CookieOptions { + maxAge?: number; + domain?: string; + maxCookieLength?: number; + sameSite?: string; + httpOnly?: boolean; + secure?: boolean; + path?: string; + [key: string]: CookieOptionValue; +} + +export const TWENTY_NINE_DAYS = 2505600; +export const MAX_COOKIE_LENGTH = 3000; + +export const GLOBAL_COOKIE_OPTIONS: CookieOptions = { + sameSite: "lax", + httpOnly: true, + path: "/", +}; + +const getRuntimeEnv = (): CookieEnv => { + // In browser/react-native bundles process is undefined + if (typeof globalThis === "undefined") { + return {}; + } + + const maybeProcess = (globalThis as { process?: { env?: CookieEnv } }) + .process; + return maybeProcess?.env ?? {}; +}; + +export function removeTrailingSlash( + url: string | undefined | null, +): string | undefined { + if (url === undefined || url === null) return undefined; + + url = url.trim(); + if (url.length === 0) { + return undefined; + } + + if (url.endsWith("/")) { + url = url.slice(0, -1); + } + + return url; +} + +export const getCookieOptions = ( + options: CookieOptions = {}, + env?: CookieEnv, +): CookieOptions => { + const resolvedEnv = env ?? getRuntimeEnv(); + const rawDomain = resolvedEnv.KINDE_COOKIE_DOMAIN; + const domainFromEnv = removeTrailingSlash(rawDomain); + const secureDefault = resolvedEnv.NODE_ENV === "production"; + + if ( + rawDomain && + domainFromEnv === undefined && + options.domain === undefined + ) { + console.warn( + "getCookieOptions: KINDE_COOKIE_DOMAIN is empty after trimming and will be ignored.", + ); + } + + const merged: CookieOptions = { + maxAge: TWENTY_NINE_DAYS, + domain: domainFromEnv, + maxCookieLength: MAX_COOKIE_LENGTH, + ...GLOBAL_COOKIE_OPTIONS, + ...options, + }; + + if (options.secure === undefined) { + merged.secure = secureDefault; + if (resolvedEnv.NODE_ENV === undefined) { + console.warn( + "getCookieOptions: NODE_ENV not set; defaulting secure cookie flag to false. Provide env or override secure to suppress this warning.", + ); + } + } + + return merged; +}; diff --git a/lib/utils/index.ts b/lib/utils/index.ts index aca17c2..cf7460e 100644 --- a/lib/utils/index.ts +++ b/lib/utils/index.ts @@ -9,6 +9,12 @@ export { frameworkSettings, generateKindeSDKHeader, } from "./exchangeAuthCode"; +export { getCookieOptions } from "./getCookieOptions"; +export type { + CookieEnv, + CookieOptions, + CookieOptionValue, +} from "./getCookieOptions"; export { checkAuth } from "./checkAuth"; export { isCustomDomain } from "./isCustomDomain"; export { setRefreshTimer, clearRefreshTimer } from "./refreshTimer";