Skip to content

Commit 9524dd0

Browse files
feat: add getCookieOptions utility and related types; update exports
1 parent 246d2a3 commit 9524dd0

File tree

5 files changed

+226
-0
lines changed

5 files changed

+226
-0
lines changed

lib/main.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ describe("index exports", () => {
4747
"mapLoginMethodParamsForUrl",
4848
"sanitizeUrl",
4949
"exchangeAuthCode",
50+
"getCookieOptions",
5051
"isAuthenticated",
5152
"isTokenExpired",
5253
"refreshToken",

lib/main.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ export {
2424
sessionManagerActivityProxy,
2525
isClient,
2626
isServer,
27+
getCookieOptions,
2728
} from "./utils";
29+
export type { CookieEnv, CookieOptions, CookieOptionValue } from "./utils";
2830

2931
export {
3032
getClaim,

lib/utils/getCookieOptions.test.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { describe, it, expect, vi } from "vitest";
2+
3+
import {
4+
getCookieOptions,
5+
removeTrailingSlash,
6+
TWENTY_NINE_DAYS,
7+
MAX_COOKIE_LENGTH,
8+
} from "./getCookieOptions";
9+
10+
describe("getCookieOptions", () => {
11+
it("returns the default configuration when env is provided", () => {
12+
const result = getCookieOptions(undefined, {
13+
NODE_ENV: "production",
14+
KINDE_COOKIE_DOMAIN: "example.com/",
15+
});
16+
17+
expect(result).toMatchObject({
18+
maxAge: TWENTY_NINE_DAYS,
19+
domain: "example.com",
20+
maxCookieLength: MAX_COOKIE_LENGTH,
21+
sameSite: "lax",
22+
httpOnly: true,
23+
path: "/",
24+
secure: true,
25+
});
26+
});
27+
28+
it("allows consumers to override default options", () => {
29+
const result = getCookieOptions(
30+
{
31+
secure: false,
32+
sameSite: "none",
33+
path: "/custom",
34+
maxAge: 60,
35+
customOption: "value",
36+
},
37+
{
38+
NODE_ENV: "production",
39+
KINDE_COOKIE_DOMAIN: "example.com",
40+
},
41+
);
42+
43+
expect(result.secure).toBe(false);
44+
expect(result.sameSite).toBe("none");
45+
expect(result.path).toBe("/custom");
46+
expect(result.maxAge).toBe(60);
47+
expect(result.customOption).toBe("value");
48+
});
49+
50+
it("falls back to runtime environment variables when env param is omitted", () => {
51+
const previousNodeEnv = process.env.NODE_ENV;
52+
const previousCookieDomain = process.env.KINDE_COOKIE_DOMAIN;
53+
54+
process.env.NODE_ENV = "production";
55+
process.env.KINDE_COOKIE_DOMAIN = "runtime-domain.io/";
56+
57+
const result = getCookieOptions();
58+
59+
expect(result.domain).toBe("runtime-domain.io");
60+
expect(result.secure).toBe(true);
61+
62+
if (previousNodeEnv === undefined) {
63+
delete process.env.NODE_ENV;
64+
} else {
65+
process.env.NODE_ENV = previousNodeEnv;
66+
}
67+
68+
if (previousCookieDomain === undefined) {
69+
delete process.env.KINDE_COOKIE_DOMAIN;
70+
} else {
71+
process.env.KINDE_COOKIE_DOMAIN = previousCookieDomain;
72+
}
73+
});
74+
75+
it("warns when NODE_ENV is missing and secure option is not provided", () => {
76+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
77+
78+
const result = getCookieOptions({}, {});
79+
80+
expect(result.secure).toBe(false);
81+
expect(warnSpy).toHaveBeenCalledWith(
82+
"getCookieOptions: NODE_ENV not set; defaulting secure cookie flag to false. Provide env or override secure to suppress this warning.",
83+
);
84+
85+
warnSpy.mockRestore();
86+
});
87+
88+
it("warns when KINDE_COOKIE_DOMAIN resolves to an empty string", () => {
89+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
90+
91+
const result = getCookieOptions(
92+
{},
93+
{ NODE_ENV: "development", KINDE_COOKIE_DOMAIN: " " },
94+
);
95+
96+
expect(result.domain).toBeUndefined();
97+
expect(warnSpy).toHaveBeenCalledWith(
98+
"getCookieOptions: KINDE_COOKIE_DOMAIN is empty after trimming and will be ignored.",
99+
);
100+
101+
warnSpy.mockRestore();
102+
});
103+
});
104+
105+
describe("removeTrailingSlash", () => {
106+
it("removes trailing slashes and trims whitespace", () => {
107+
expect(removeTrailingSlash("example.com/")).toBe("example.com");
108+
expect(removeTrailingSlash(" example.com/ ")).toBe("example.com");
109+
});
110+
111+
it("returns the original string when there is no trailing slash", () => {
112+
expect(removeTrailingSlash("example.com")).toBe("example.com");
113+
});
114+
115+
it("returns undefined for nullish values", () => {
116+
expect(removeTrailingSlash(undefined)).toBeUndefined();
117+
expect(removeTrailingSlash(null)).toBeUndefined();
118+
});
119+
120+
it("returns undefined for whitespace-only strings", () => {
121+
expect(removeTrailingSlash(" ")).toBeUndefined();
122+
});
123+
});

lib/utils/getCookieOptions.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
export interface CookieEnv {
2+
NODE_ENV?: string;
3+
KINDE_COOKIE_DOMAIN?: string;
4+
[key: string]: string | undefined;
5+
}
6+
7+
export type CookieOptionValue = string | number | boolean | undefined | null;
8+
9+
export interface CookieOptions {
10+
maxAge?: number;
11+
domain?: string;
12+
maxCookieLength?: number;
13+
sameSite?: string;
14+
httpOnly?: boolean;
15+
secure?: boolean;
16+
path?: string;
17+
[key: string]: CookieOptionValue;
18+
}
19+
20+
export const TWENTY_NINE_DAYS = 2505600;
21+
export const MAX_COOKIE_LENGTH = 3000;
22+
23+
export const GLOBAL_COOKIE_OPTIONS: CookieOptions = {
24+
sameSite: "lax",
25+
httpOnly: true,
26+
path: "/",
27+
};
28+
29+
const getRuntimeEnv = (): CookieEnv => {
30+
// In browser/react-native bundles process is undefined
31+
if (typeof globalThis === "undefined") {
32+
return {};
33+
}
34+
35+
const maybeProcess = (globalThis as { process?: { env?: CookieEnv } })
36+
.process;
37+
return maybeProcess?.env ?? {};
38+
};
39+
40+
export function removeTrailingSlash(
41+
url: string | undefined | null,
42+
): string | undefined {
43+
if (url === undefined || url === null) return undefined;
44+
45+
url = url.trim();
46+
if (url.length === 0) {
47+
return undefined;
48+
}
49+
50+
if (url.endsWith("/")) {
51+
url = url.slice(0, -1);
52+
}
53+
54+
return url;
55+
}
56+
57+
export const getCookieOptions = (
58+
options: CookieOptions = {},
59+
env?: CookieEnv,
60+
): CookieOptions => {
61+
const resolvedEnv = env ?? getRuntimeEnv();
62+
const rawDomain = resolvedEnv.KINDE_COOKIE_DOMAIN;
63+
const domainFromEnv = removeTrailingSlash(rawDomain);
64+
const secureDefault = resolvedEnv.NODE_ENV === "production";
65+
66+
if (
67+
rawDomain &&
68+
domainFromEnv === undefined &&
69+
options.domain === undefined
70+
) {
71+
console.warn(
72+
"getCookieOptions: KINDE_COOKIE_DOMAIN is empty after trimming and will be ignored.",
73+
);
74+
}
75+
76+
const merged: CookieOptions = {
77+
maxAge: TWENTY_NINE_DAYS,
78+
domain: domainFromEnv,
79+
maxCookieLength: MAX_COOKIE_LENGTH,
80+
...GLOBAL_COOKIE_OPTIONS,
81+
...options,
82+
};
83+
84+
if (options.secure === undefined) {
85+
merged.secure = secureDefault;
86+
if (resolvedEnv.NODE_ENV === undefined) {
87+
console.warn(
88+
"getCookieOptions: NODE_ENV not set; defaulting secure cookie flag to false. Provide env or override secure to suppress this warning.",
89+
);
90+
}
91+
}
92+
93+
return merged;
94+
};

lib/utils/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ export {
99
frameworkSettings,
1010
generateKindeSDKHeader,
1111
} from "./exchangeAuthCode";
12+
export { getCookieOptions } from "./getCookieOptions";
13+
export type {
14+
CookieEnv,
15+
CookieOptions,
16+
CookieOptionValue,
17+
} from "./getCookieOptions";
1218
export { checkAuth } from "./checkAuth";
1319
export { isCustomDomain } from "./isCustomDomain";
1420
export { setRefreshTimer, clearRefreshTimer } from "./refreshTimer";

0 commit comments

Comments
 (0)