From 9524dd00fc05f311ef08c04ea98fe09fd38f43a0 Mon Sep 17 00:00:00 2001 From: dtoxvanilla1991 Date: Tue, 21 Oct 2025 22:09:17 -0500 Subject: [PATCH 1/8] feat: add getCookieOptions utility and related types; update exports --- lib/main.test.ts | 1 + lib/main.ts | 2 + lib/utils/getCookieOptions.test.ts | 123 +++++++++++++++++++++++++++++ lib/utils/getCookieOptions.ts | 94 ++++++++++++++++++++++ lib/utils/index.ts | 6 ++ 5 files changed, 226 insertions(+) create mode 100644 lib/utils/getCookieOptions.test.ts create mode 100644 lib/utils/getCookieOptions.ts diff --git a/lib/main.test.ts b/lib/main.test.ts index 6338656a..7dd52fa4 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 99381da1..03a4aaa9 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/getCookieOptions.test.ts b/lib/utils/getCookieOptions.test.ts new file mode 100644 index 00000000..d855be84 --- /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 00000000..d6fed83f --- /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 aca17c26..cf7460ea 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"; From 0c1c8c0969ed4d874987dab9c7257838226d8e03 Mon Sep 17 00:00:00 2001 From: dtoxvanilla1991 Date: Tue, 21 Oct 2025 22:27:30 -0500 Subject: [PATCH 2/8] refactor: update fetch mock implementation and response handling in exchangeAuthCode tests --- lib/main.test.ts | 2 +- lib/utils/exchangeAuthCode.test.ts | 72 ++++++++++++++++-------------- 2 files changed, 40 insertions(+), 34 deletions(-) diff --git a/lib/main.test.ts b/lib/main.test.ts index 7dd52fa4..9a5a807a 100644 --- a/lib/main.test.ts +++ b/lib/main.test.ts @@ -47,7 +47,7 @@ describe("index exports", () => { "mapLoginMethodParamsForUrl", "sanitizeUrl", "exchangeAuthCode", - "getCookieOptions", + "getCookieOptions", "isAuthenticated", "isTokenExpired", "refreshToken", diff --git a/lib/utils/exchangeAuthCode.test.ts b/lib/utils/exchangeAuthCode.test.ts index 523f769b..0f156df9 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, From 9d615c1f87d30db80199c8d854daa08ba15738e1 Mon Sep 17 00:00:00 2001 From: dtoxvanilla1991 Date: Wed, 17 Dec 2025 20:34:23 -0500 Subject: [PATCH 3/8] test: update fetch mock to use mockResolvedValueOnce for async response --- lib/utils/exchangeAuthCode.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/utils/exchangeAuthCode.test.ts b/lib/utils/exchangeAuthCode.test.ts index 1092e76a..bce2c7bc 100644 --- a/lib/utils/exchangeAuthCode.test.ts +++ b/lib/utils/exchangeAuthCode.test.ts @@ -597,8 +597,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", From 842080965510347c1078d29f23c012b38a46d44b Mon Sep 17 00:00:00 2001 From: dtoxvanilla1991 Date: Thu, 18 Dec 2025 07:01:25 -0500 Subject: [PATCH 4/8] test: refactor fetch mock implementation for exchangeAuthCode tests --- lib/utils/exchangeAuthCode.test.ts | 76 ++++++++++++++---------------- 1 file changed, 35 insertions(+), 41 deletions(-) diff --git a/lib/utils/exchangeAuthCode.test.ts b/lib/utils/exchangeAuthCode.test.ts index bce2c7bc..e5ee9bbe 100644 --- a/lib/utils/exchangeAuthCode.test.ts +++ b/lib/utils/exchangeAuthCode.test.ts @@ -6,19 +6,12 @@ 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 = vi.fn(); - -const jsonResponse = (data: unknown, init?: ResponseInit) => - new Response(JSON.stringify(data), { - headers: { - "Content-Type": "application/json", - }, - ...init, - }); +const fetchMock = createFetchMock(vi); describe("exchangeAuthCode", () => { const mockStorage = { @@ -33,8 +26,7 @@ describe("exchangeAuthCode", () => { }; beforeEach(() => { - fetchMock.mockReset(); - vi.stubGlobal("fetch", fetchMock); + fetchMock.enableMocks(); vi.spyOn(refreshTokenTimer, "setRefreshTimer"); vi.spyOn(main, "refreshToken"); vi.useFakeTimers(); @@ -43,8 +35,7 @@ describe("exchangeAuthCode", () => { }); afterEach(() => { - vi.unstubAllGlobals(); - vi.restoreAllMocks(); + fetchMock.resetMocks(); vi.useRealTimers(); }); @@ -143,8 +134,8 @@ describe("exchangeAuthCode", () => { urlParams.append("state", state); urlParams.append("client_id", "test"); - fetchMock.mockResolvedValueOnce( - jsonResponse({ + fetchMock.mockResponseOnce( + JSON.stringify({ access_token: "access_token", refresh_token: "refresh_token", id_token: "id_token", @@ -200,8 +191,8 @@ describe("exchangeAuthCode", () => { urlParams.append("state", state); urlParams.append("client_id", "test"); - fetchMock.mockResolvedValueOnce( - jsonResponse({ + fetchMock.mockResponseOnce( + JSON.stringify({ access_token: "access_token", refresh_token: "refresh_token", id_token: "id_token", @@ -256,8 +247,8 @@ describe("exchangeAuthCode", () => { urlParams.append("state", state); urlParams.append("client_id", "test"); - fetchMock.mockResolvedValueOnce( - jsonResponse({ + fetchMock.mockResponseOnce( + JSON.stringify({ access_token: "access_token", refresh_token: "refresh_token", id_token: "id_token", @@ -301,12 +292,7 @@ describe("exchangeAuthCode", () => { urlParams.append("state", state); urlParams.append("client_id", "test"); - fetchMock.mockResolvedValueOnce( - new Response("error", { - status: 500, - statusText: "Internal Server Error", - }), - ); + fetchMock.mockOnce({ status: 500, ok: false, body: "error" }); const result = await exchangeAuthCode({ urlParams, @@ -342,8 +328,8 @@ describe("exchangeAuthCode", () => { urlParams.append("state", state); urlParams.append("client_id", "test"); - fetchMock.mockResolvedValueOnce( - jsonResponse({ + fetchMock.mockResponseOnce( + JSON.stringify({ access_token: "access_token", refresh_token: "refresh_token", id_token: "id_token", @@ -458,7 +444,7 @@ describe("exchangeAuthCode", () => { if (key === StorageKeys.codeVerifier) return "verifier"; return null; }); - fetchMock.mockRejectedValueOnce(new Error("Fetch failed")); + fetchMock.mockRejectOnce(new Error("Fetch failed")); await expect( exchangeAuthCode({ @@ -486,7 +472,10 @@ describe("exchangeAuthCode", () => { if (key === StorageKeys.codeVerifier) return "verifier"; return null; }); - fetchMock.mockResolvedValueOnce(jsonResponse({})); + vi.mocked(global.fetch).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({}), + } as Response); const result = await exchangeAuthCode({ urlParams, @@ -514,7 +503,10 @@ describe("exchangeAuthCode", () => { if (key === StorageKeys.codeVerifier) return "verifier"; return null; }); - fetchMock.mockResolvedValueOnce(jsonResponse({})); + vi.mocked(global.fetch).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({}), + } as Response); await exchangeAuthCode({ urlParams, @@ -524,14 +516,14 @@ describe("exchangeAuthCode", () => { clientSecret: "secret", }); - expect(fetchMock).toHaveBeenCalledWith( + expect(global.fetch).toHaveBeenCalledWith( "test.com/oauth2/token", expect.objectContaining({ body: expect.any(URLSearchParams), }), ); - const fetchCall = fetchMock.mock.calls[0]; + const fetchCall = vi.mocked(global.fetch).mock.calls[0]; const actualBody = fetchCall[1]?.body as URLSearchParams; expect(actualBody.get("client_id")).toBe("test"); @@ -560,13 +552,15 @@ describe("exchangeAuthCode", () => { return null; }); - fetchMock.mockResolvedValue( - jsonResponse({ - access_token: "access", - id_token: "id", - refresh_token: "refresh", - }), - ); + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + access_token: "access", + id_token: "id", + refresh_token: "refresh", + }), + } as Response); const result = await exchangeAuthCode({ urlParams, @@ -597,8 +591,8 @@ describe("exchangeAuthCode", () => { urlParams.append("state", "state"); urlParams.append("client_id", "test"); - fetchMock.mockResolvedValueOnce( - jsonResponse({ + fetchMock.mockResponseOnce( + JSON.stringify({ access_token: "access_token", refresh_token: "refresh_token", id_token: "id_token", From 5fbb6be9114f9ba43f375dccbc7c83bf180b82ea Mon Sep 17 00:00:00 2001 From: dtoxvanilla1991 Date: Thu, 18 Dec 2025 13:21:22 -0500 Subject: [PATCH 5/8] refactor: simplify cookie options types and remove unused code --- lib/main.ts | 2 +- lib/utils/getCookieOptions.test.ts | 131 ++++++++++------------------- lib/utils/getCookieOptions.ts | 74 ++-------------- lib/utils/index.ts | 6 +- 4 files changed, 53 insertions(+), 160 deletions(-) diff --git a/lib/main.ts b/lib/main.ts index baacd4b6..42d3cbfe 100644 --- a/lib/main.ts +++ b/lib/main.ts @@ -26,7 +26,7 @@ export { isServer, getCookieOptions, } from "./utils"; -export type { CookieEnv, CookieOptions, CookieOptionValue } from "./utils"; +export type { CookieOptions, CookieOptionValue } from "./utils"; export { getClaim, diff --git a/lib/utils/getCookieOptions.test.ts b/lib/utils/getCookieOptions.test.ts index d855be84..11352a76 100644 --- a/lib/utils/getCookieOptions.test.ts +++ b/lib/utils/getCookieOptions.test.ts @@ -1,123 +1,84 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect } from "vitest"; import { getCookieOptions, - removeTrailingSlash, TWENTY_NINE_DAYS, MAX_COOKIE_LENGTH, + GLOBAL_COOKIE_OPTIONS, } 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/", - }); + it("returns the default configuration when no options provided", () => { + const result = getCookieOptions(); 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", - }, - ); + const result = getCookieOptions({ + secure: true, + sameSite: "none", + path: "/custom", + maxAge: 60, + domain: "example.com", + customOption: "value", + }); - expect(result.secure).toBe(false); + expect(result.secure).toBe(true); expect(result.sameSite).toBe("none"); expect(result.path).toBe("/custom"); expect(result.maxAge).toBe(60); + expect(result.domain).toBe("example.com"); 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; - } + it("preserves GLOBAL_COOKIE_OPTIONS when not overridden", () => { + const result = getCookieOptions({ domain: "test.com" }); - if (previousCookieDomain === undefined) { - delete process.env.KINDE_COOKIE_DOMAIN; - } else { - process.env.KINDE_COOKIE_DOMAIN = previousCookieDomain; - } + expect(result.httpOnly).toBe(GLOBAL_COOKIE_OPTIONS.httpOnly); + expect(result.sameSite).toBe(GLOBAL_COOKIE_OPTIONS.sameSite); + expect(result.path).toBe(GLOBAL_COOKIE_OPTIONS.path); + expect(result.maxAge).toBe(GLOBAL_COOKIE_OPTIONS.maxAge); + expect(result.maxCookieLength).toBe(GLOBAL_COOKIE_OPTIONS.maxCookieLength); + expect(result.domain).toBe("test.com"); }); - 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.", - ); + it("user options take precedence over defaults", () => { + const result = getCookieOptions({ + httpOnly: false, + maxAge: 1000, + }); - warnSpy.mockRestore(); + expect(result.httpOnly).toBe(false); + expect(result.maxAge).toBe(1000); + // Other defaults remain + expect(result.sameSite).toBe("lax"); + expect(result.path).toBe("/"); }); - 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.", - ); + it("supports custom passthrough options", () => { + const result = getCookieOptions({ + customFlag: true, + anotherOption: "test", + }); - warnSpy.mockRestore(); + expect(result.customFlag).toBe(true); + expect(result.anotherOption).toBe("test"); }); }); -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(); +describe("GLOBAL_COOKIE_OPTIONS", () => { + it("contains secure defaults", () => { + expect(GLOBAL_COOKIE_OPTIONS.httpOnly).toBe(true); + expect(GLOBAL_COOKIE_OPTIONS.sameSite).toBe("lax"); + expect(GLOBAL_COOKIE_OPTIONS.path).toBe("/"); + expect(GLOBAL_COOKIE_OPTIONS.maxAge).toBe(TWENTY_NINE_DAYS); + expect(GLOBAL_COOKIE_OPTIONS.maxCookieLength).toBe(MAX_COOKIE_LENGTH); }); }); diff --git a/lib/utils/getCookieOptions.ts b/lib/utils/getCookieOptions.ts index d6fed83f..0c542a24 100644 --- a/lib/utils/getCookieOptions.ts +++ b/lib/utils/getCookieOptions.ts @@ -1,10 +1,4 @@ -export interface CookieEnv { - NODE_ENV?: string; - KINDE_COOKIE_DOMAIN?: string; - [key: string]: string | undefined; -} - -export type CookieOptionValue = string | number | boolean | undefined | null; +export type CookieOptionValue = string | number | boolean | undefined; export interface CookieOptions { maxAge?: number; @@ -21,74 +15,16 @@ export const TWENTY_NINE_DAYS = 2505600; export const MAX_COOKIE_LENGTH = 3000; export const GLOBAL_COOKIE_OPTIONS: CookieOptions = { + maxAge: TWENTY_NINE_DAYS, + maxCookieLength: MAX_COOKIE_LENGTH, 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, +export const getCookieOptions = (options: CookieOptions = {}): CookieOptions => { + return { ...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 cf7460ea..723231d4 100644 --- a/lib/utils/index.ts +++ b/lib/utils/index.ts @@ -10,11 +10,7 @@ export { generateKindeSDKHeader, } from "./exchangeAuthCode"; export { getCookieOptions } from "./getCookieOptions"; -export type { - CookieEnv, - CookieOptions, - CookieOptionValue, -} from "./getCookieOptions"; +export type { CookieOptions, CookieOptionValue } from "./getCookieOptions"; export { checkAuth } from "./checkAuth"; export { isCustomDomain } from "./isCustomDomain"; export { setRefreshTimer, clearRefreshTimer } from "./refreshTimer"; From 557652dc7abdcbae55e6437d2bbb30586c58a72a Mon Sep 17 00:00:00 2001 From: dtoxvanilla1991 Date: Thu, 18 Dec 2025 13:31:24 -0500 Subject: [PATCH 6/8] style: format getCookieOptions function for improved readability --- lib/utils/getCookieOptions.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/utils/getCookieOptions.ts b/lib/utils/getCookieOptions.ts index 0c542a24..db3122ed 100644 --- a/lib/utils/getCookieOptions.ts +++ b/lib/utils/getCookieOptions.ts @@ -22,7 +22,9 @@ export const GLOBAL_COOKIE_OPTIONS: CookieOptions = { path: "/", }; -export const getCookieOptions = (options: CookieOptions = {}): CookieOptions => { +export const getCookieOptions = ( + options: CookieOptions = {}, +): CookieOptions => { return { ...GLOBAL_COOKIE_OPTIONS, ...options, From 2875e9338ed00eab38809ddc02a29a4d68351108 Mon Sep 17 00:00:00 2001 From: dtoxvanilla1991 Date: Thu, 18 Dec 2025 18:05:45 -0500 Subject: [PATCH 7/8] docs: enhance documentation for GLOBAL_COOKIE_OPTIONS and getCookieOptions function --- lib/utils/getCookieOptions.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/lib/utils/getCookieOptions.ts b/lib/utils/getCookieOptions.ts index db3122ed..1b119a47 100644 --- a/lib/utils/getCookieOptions.ts +++ b/lib/utils/getCookieOptions.ts @@ -14,6 +14,17 @@ export interface CookieOptions { export const TWENTY_NINE_DAYS = 2505600; export const MAX_COOKIE_LENGTH = 3000; +/** + * Default cookie options used across Kinde SDKs. + * + * **Security Note:** The `secure` flag is intentionally omitted to support: + * - Framework-agnostic usage across different environments + * - Local development over HTTP (localhost) + * + * Warning: For production deployments using HTTPS, consumers must explicitly + * set `secure: true` via `getCookieOptions({ secure: true })` to ensure + * cookies are only transmitted over secure connections. + */ export const GLOBAL_COOKIE_OPTIONS: CookieOptions = { maxAge: TWENTY_NINE_DAYS, maxCookieLength: MAX_COOKIE_LENGTH, @@ -22,6 +33,24 @@ export const GLOBAL_COOKIE_OPTIONS: CookieOptions = { path: "/", }; +/** + * Returns cookie options by merging provided options with secure defaults. + * + * @param options - Custom cookie options to override defaults + * @returns Merged cookie options with GLOBAL_COOKIE_OPTIONS as base + * + * @example + * ```typescript + * // Development (HTTP) + * const devOptions = getCookieOptions(); + * + * // Production (HTTPS) - must set secure: true + * const prodOptions = getCookieOptions({ secure: true, domain: ".example.com" }); + * ``` + * + * **Security Warning:** Always set `secure: true` in production environments + * using HTTPS to prevent cookie transmission over insecure connections. + */ export const getCookieOptions = ( options: CookieOptions = {}, ): CookieOptions => { From 70e91893a1d93bac74cd7f266811db306ce0dc9d Mon Sep 17 00:00:00 2001 From: dtoxvanilla1991 Date: Sat, 20 Dec 2025 18:30:03 -0500 Subject: [PATCH 8/8] refactor: update cookie options to use storageSettings for maxCookieLength: Daniel feedback --- lib/main.ts | 2 +- lib/utils/getCookieOptions.test.ts | 20 +++++--------------- lib/utils/getCookieOptions.ts | 6 ++---- lib/utils/index.ts | 2 +- 4 files changed, 9 insertions(+), 21 deletions(-) diff --git a/lib/main.ts b/lib/main.ts index 42d3cbfe..e2645eaa 100644 --- a/lib/main.ts +++ b/lib/main.ts @@ -26,7 +26,7 @@ export { isServer, getCookieOptions, } from "./utils"; -export type { CookieOptions, CookieOptionValue } from "./utils"; +export type { CookieOptions } from "./utils"; export { getClaim, diff --git a/lib/utils/getCookieOptions.test.ts b/lib/utils/getCookieOptions.test.ts index 11352a76..71117340 100644 --- a/lib/utils/getCookieOptions.test.ts +++ b/lib/utils/getCookieOptions.test.ts @@ -3,9 +3,9 @@ import { describe, it, expect } from "vitest"; import { getCookieOptions, TWENTY_NINE_DAYS, - MAX_COOKIE_LENGTH, GLOBAL_COOKIE_OPTIONS, } from "./getCookieOptions"; +import { storageSettings } from "../sessionManager/index"; describe("getCookieOptions", () => { it("returns the default configuration when no options provided", () => { @@ -13,7 +13,7 @@ describe("getCookieOptions", () => { expect(result).toMatchObject({ maxAge: TWENTY_NINE_DAYS, - maxCookieLength: MAX_COOKIE_LENGTH, + maxCookieLength: storageSettings.maxLength, sameSite: "lax", httpOnly: true, path: "/", @@ -27,7 +27,6 @@ describe("getCookieOptions", () => { path: "/custom", maxAge: 60, domain: "example.com", - customOption: "value", }); expect(result.secure).toBe(true); @@ -35,7 +34,6 @@ describe("getCookieOptions", () => { expect(result.path).toBe("/custom"); expect(result.maxAge).toBe(60); expect(result.domain).toBe("example.com"); - expect(result.customOption).toBe("value"); }); it("preserves GLOBAL_COOKIE_OPTIONS when not overridden", () => { @@ -61,16 +59,6 @@ describe("getCookieOptions", () => { expect(result.sameSite).toBe("lax"); expect(result.path).toBe("/"); }); - - it("supports custom passthrough options", () => { - const result = getCookieOptions({ - customFlag: true, - anotherOption: "test", - }); - - expect(result.customFlag).toBe(true); - expect(result.anotherOption).toBe("test"); - }); }); describe("GLOBAL_COOKIE_OPTIONS", () => { @@ -79,6 +67,8 @@ describe("GLOBAL_COOKIE_OPTIONS", () => { expect(GLOBAL_COOKIE_OPTIONS.sameSite).toBe("lax"); expect(GLOBAL_COOKIE_OPTIONS.path).toBe("/"); expect(GLOBAL_COOKIE_OPTIONS.maxAge).toBe(TWENTY_NINE_DAYS); - expect(GLOBAL_COOKIE_OPTIONS.maxCookieLength).toBe(MAX_COOKIE_LENGTH); + expect(GLOBAL_COOKIE_OPTIONS.maxCookieLength).toBe( + storageSettings.maxLength, + ); }); }); diff --git a/lib/utils/getCookieOptions.ts b/lib/utils/getCookieOptions.ts index 1b119a47..fbf4c5e7 100644 --- a/lib/utils/getCookieOptions.ts +++ b/lib/utils/getCookieOptions.ts @@ -1,4 +1,4 @@ -export type CookieOptionValue = string | number | boolean | undefined; +import { storageSettings } from "../sessionManager/index.js"; export interface CookieOptions { maxAge?: number; @@ -8,11 +8,9 @@ export interface CookieOptions { httpOnly?: boolean; secure?: boolean; path?: string; - [key: string]: CookieOptionValue; } export const TWENTY_NINE_DAYS = 2505600; -export const MAX_COOKIE_LENGTH = 3000; /** * Default cookie options used across Kinde SDKs. @@ -27,7 +25,7 @@ export const MAX_COOKIE_LENGTH = 3000; */ export const GLOBAL_COOKIE_OPTIONS: CookieOptions = { maxAge: TWENTY_NINE_DAYS, - maxCookieLength: MAX_COOKIE_LENGTH, + maxCookieLength: storageSettings.maxLength, sameSite: "lax", httpOnly: true, path: "/", diff --git a/lib/utils/index.ts b/lib/utils/index.ts index 723231d4..4cbc9323 100644 --- a/lib/utils/index.ts +++ b/lib/utils/index.ts @@ -10,7 +10,7 @@ export { generateKindeSDKHeader, } from "./exchangeAuthCode"; export { getCookieOptions } from "./getCookieOptions"; -export type { CookieOptions, CookieOptionValue } from "./getCookieOptions"; +export type { CookieOptions } from "./getCookieOptions"; export { checkAuth } from "./checkAuth"; export { isCustomDomain } from "./isCustomDomain"; export { setRefreshTimer, clearRefreshTimer } from "./refreshTimer";