@convex-dev/better-auth 0.10.10 → 0.10.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/client/adapter-utils.d.ts.map +1 -1
  2. package/dist/client/adapter-utils.js +1 -1
  3. package/dist/client/adapter-utils.js.map +1 -1
  4. package/dist/client/adapter.js +1 -1
  5. package/dist/client/adapter.js.map +1 -1
  6. package/dist/client/create-api.d.ts +2 -2
  7. package/dist/component/adapter.d.ts +2 -2
  8. package/dist/component/adapterTest.js +4 -2
  9. package/dist/component/adapterTest.js.map +1 -1
  10. package/dist/nextjs/index.d.ts.map +1 -1
  11. package/dist/nextjs/index.js +4 -1
  12. package/dist/nextjs/index.js.map +1 -1
  13. package/dist/plugins/convex/index.d.ts.map +1 -1
  14. package/dist/plugins/convex/index.js +29 -12
  15. package/dist/plugins/convex/index.js.map +1 -1
  16. package/dist/plugins/cross-domain/client.d.ts +1 -1
  17. package/dist/plugins/cross-domain/client.d.ts.map +1 -1
  18. package/dist/plugins/cross-domain/client.js +33 -5
  19. package/dist/plugins/cross-domain/client.js.map +1 -1
  20. package/dist/plugins/cross-domain/index.d.ts.map +1 -1
  21. package/dist/plugins/cross-domain/index.js +11 -6
  22. package/dist/plugins/cross-domain/index.js.map +1 -1
  23. package/dist/react/index.d.ts.map +1 -1
  24. package/dist/react/index.js +24 -12
  25. package/dist/react/index.js.map +1 -1
  26. package/dist/react-start/index.d.ts.map +1 -1
  27. package/dist/react-start/index.js +4 -1
  28. package/dist/react-start/index.js.map +1 -1
  29. package/package.json +8 -8
  30. package/src/client/adapter-utils.ts +3 -2
  31. package/src/client/adapter.ts +1 -1
  32. package/src/component/adapterTest.ts +4 -2
  33. package/src/nextjs/index.ts +4 -1
  34. package/src/plugins/convex/index.ts +31 -14
  35. package/src/plugins/cross-domain/client.test.ts +315 -0
  36. package/src/plugins/cross-domain/client.ts +35 -7
  37. package/src/plugins/cross-domain/index.ts +16 -7
  38. package/src/react/index.tsx +28 -15
  39. package/src/react-start/index.ts +4 -1
@@ -0,0 +1,315 @@
1
+ import { describe, it, expect, beforeEach, vi } from "vitest";
2
+ import { getCookie, getSetCookie, parseSetCookieHeader, crossDomainClient } from "./client.js";
3
+
4
+ describe("parseSetCookieHeader", () => {
5
+ it("parses a simple cookie", () => {
6
+ const header = "session_token=abc123";
7
+ const map = parseSetCookieHeader(header);
8
+ expect(map.get("session_token")?.value).toBe("abc123");
9
+ });
10
+
11
+ it("parses cookie with attributes", () => {
12
+ const header = "session_token=abc123; Path=/; Secure; HttpOnly";
13
+ const map = parseSetCookieHeader(header);
14
+ const cookie = map.get("session_token");
15
+ expect(cookie?.value).toBe("abc123");
16
+ });
17
+
18
+ it("parses multiple cookies", () => {
19
+ const header = "a=1, b=2";
20
+ const map = parseSetCookieHeader(header);
21
+ expect(map.get("a")?.value).toBe("1");
22
+ expect(map.get("b")?.value).toBe("2");
23
+ });
24
+ });
25
+
26
+ describe("getSetCookie", () => {
27
+ it("stores expires as ISO string", () => {
28
+ const header = "session_token=abc; Max-Age=3600";
29
+ const result = JSON.parse(getSetCookie(header));
30
+ expect(typeof result.session_token.expires).toBe("string");
31
+ expect(result.session_token.expires).toMatch(/^\d{4}-\d{2}-\d{2}T/);
32
+ });
33
+
34
+ it("stores null expires when no expiry is set", () => {
35
+ const header = "session_token=abc";
36
+ const result = JSON.parse(getSetCookie(header));
37
+ expect(result.session_token.expires).toBeNull();
38
+ });
39
+
40
+ it("merges with previous cookies", () => {
41
+ const prev = JSON.stringify({
42
+ old_cookie: { value: "old", expires: null },
43
+ });
44
+ const header = "new_cookie=new";
45
+ const result = JSON.parse(getSetCookie(header, prev));
46
+ expect(result.old_cookie.value).toBe("old");
47
+ expect(result.new_cookie.value).toBe("new");
48
+ });
49
+
50
+ it("overwrites previous cookies with same name", () => {
51
+ const prev = JSON.stringify({
52
+ token: { value: "old", expires: null },
53
+ });
54
+ const header = "token=new";
55
+ const result = JSON.parse(getSetCookie(header, prev));
56
+ expect(result.token.value).toBe("new");
57
+ });
58
+
59
+ it("survives invalid previous cookie JSON", () => {
60
+ const header = "token=abc";
61
+ const result = JSON.parse(getSetCookie(header, "not-json"));
62
+ expect(result.token.value).toBe("abc");
63
+ });
64
+ });
65
+
66
+ describe("getCookie", () => {
67
+ it("returns cookie string for valid cookies", () => {
68
+ const stored = JSON.stringify({
69
+ session: { value: "abc", expires: new Date(Date.now() + 60000).toISOString() },
70
+ });
71
+ const result = getCookie(stored);
72
+ expect(result).toContain("session=abc");
73
+ });
74
+
75
+ it("filters out expired cookies", () => {
76
+ const stored = JSON.stringify({
77
+ expired: { value: "old", expires: new Date(Date.now() - 60000).toISOString() },
78
+ valid: { value: "new", expires: new Date(Date.now() + 60000).toISOString() },
79
+ });
80
+ const result = getCookie(stored);
81
+ expect(result).not.toContain("expired=old");
82
+ expect(result).toContain("valid=new");
83
+ });
84
+
85
+ it("keeps cookies with no expiry", () => {
86
+ const stored = JSON.stringify({
87
+ session: { value: "abc", expires: null },
88
+ });
89
+ const result = getCookie(stored);
90
+ expect(result).toContain("session=abc");
91
+ });
92
+
93
+ it("handles expires after JSON round-trip (string, not Date)", () => {
94
+ // This is the core bug #1 scenario: expires is a string after JSON.parse
95
+ const past = new Date(Date.now() - 60000);
96
+ const stored = JSON.stringify({
97
+ expired: { value: "old", expires: past.toISOString() },
98
+ });
99
+ // After JSON.parse, expires is a string — getCookie must handle this
100
+ const result = getCookie(stored);
101
+ expect(result).not.toContain("expired=old");
102
+ });
103
+
104
+ it("returns empty string for empty cookie object", () => {
105
+ expect(getCookie("{}")).toBe("");
106
+ });
107
+
108
+ it("returns empty string for invalid JSON", () => {
109
+ expect(getCookie("not-json")).toBe("");
110
+ });
111
+ });
112
+
113
+ describe("crossDomainClient", () => {
114
+ let storage: Map<string, string>;
115
+ let mockStorage: { getItem: (key: string) => string | null; setItem: (key: string, value: string) => void };
116
+ const cookieName = "better-auth_cookie";
117
+ const localCacheName = "better-auth_session_data";
118
+
119
+ beforeEach(() => {
120
+ storage = new Map<string, string>();
121
+ mockStorage = {
122
+ getItem: (key) => storage.get(key) ?? null,
123
+ setItem: (key, value) => { storage.set(key, value); },
124
+ };
125
+ });
126
+
127
+ function getActions() {
128
+ const plugin = crossDomainClient({ storage: mockStorage });
129
+ const mockStore = {
130
+ notify: () => {},
131
+ atoms: { session: { set: () => {}, get: () => ({}) } },
132
+ };
133
+ return plugin.getActions({} as any, mockStore as any);
134
+ }
135
+
136
+ function getOnSuccessHook() {
137
+ const plugin = crossDomainClient({ storage: mockStorage });
138
+ return plugin.fetchPlugins[0].hooks!.onSuccess!;
139
+ }
140
+
141
+ function getOnSuccessHookWithStore() {
142
+ const plugin = crossDomainClient({ storage: mockStorage });
143
+ const notify = vi.fn();
144
+ const mockStore = {
145
+ notify,
146
+ atoms: { session: { set: () => {}, get: () => ({}) } },
147
+ };
148
+ // getActions sets the internal store reference
149
+ plugin.getActions({} as any, mockStore as any);
150
+ return { onSuccess: plugin.fetchPlugins[0].hooks!.onSuccess!, notify };
151
+ }
152
+
153
+ describe("getSessionData", () => {
154
+ it("returns null when storage is empty", () => {
155
+ const actions = getActions();
156
+ expect(actions.getSessionData()).toBeNull();
157
+ });
158
+
159
+ it("returns null for empty object in storage", () => {
160
+ storage.set(localCacheName, "{}");
161
+ const actions = getActions();
162
+ expect(actions.getSessionData()).toBeNull();
163
+ });
164
+
165
+ it("returns parsed session data", () => {
166
+ const sessionData = { session: { id: "123" }, user: { name: "test" } };
167
+ storage.set(localCacheName, JSON.stringify(sessionData));
168
+ const actions = getActions();
169
+ expect(actions.getSessionData()).toEqual(sessionData);
170
+ });
171
+
172
+ it("returns null for stored 'null' string", () => {
173
+ storage.set(localCacheName, "null");
174
+ const actions = getActions();
175
+ expect(actions.getSessionData()).toBeNull();
176
+ });
177
+
178
+ it("returns null for corrupt JSON in storage", () => {
179
+ storage.set(localCacheName, "not-valid-json");
180
+ const actions = getActions();
181
+ expect(actions.getSessionData()).toBeNull();
182
+ });
183
+ });
184
+
185
+ describe("onSuccess handler", () => {
186
+ it("clears cookies when get-session returns null", async () => {
187
+ storage.set(cookieName, JSON.stringify({
188
+ "better-auth.session_token": { value: "stale", expires: null },
189
+ }));
190
+
191
+ const onSuccess = getOnSuccessHook();
192
+ await onSuccess({
193
+ data: null,
194
+ request: { url: new URL("https://example.com/api/auth/get-session") },
195
+ response: { headers: new Headers() },
196
+ } as any);
197
+
198
+ expect(storage.get(cookieName)).toBe("{}");
199
+ });
200
+
201
+ it("preserves cookies when get-session returns data", async () => {
202
+ const existingCookies = JSON.stringify({
203
+ "better-auth.session_token": { value: "valid", expires: null },
204
+ });
205
+ storage.set(cookieName, existingCookies);
206
+
207
+ const onSuccess = getOnSuccessHook();
208
+ await onSuccess({
209
+ data: { session: { id: "123" }, user: { name: "test" } },
210
+ request: { url: new URL("https://example.com/api/auth/get-session") },
211
+ response: { headers: new Headers() },
212
+ } as any);
213
+
214
+ expect(storage.get(cookieName)).toBe(existingCookies);
215
+ });
216
+
217
+ it("caches session data on get-session", async () => {
218
+ const sessionData = { session: { id: "123" }, user: { name: "test" } };
219
+ const onSuccess = getOnSuccessHook();
220
+ await onSuccess({
221
+ data: sessionData,
222
+ request: { url: new URL("https://example.com/api/auth/get-session") },
223
+ response: { headers: new Headers() },
224
+ } as any);
225
+
226
+ expect(storage.get(localCacheName)).toBe(JSON.stringify(sessionData));
227
+ });
228
+ });
229
+
230
+ describe("session signal notification", () => {
231
+ it("notifies when session token changes", async () => {
232
+ const { onSuccess, notify } = getOnSuccessHookWithStore();
233
+ await onSuccess({
234
+ data: null,
235
+ request: { url: new URL("https://example.com/api/auth/sign-in") },
236
+ response: {
237
+ headers: new Headers({
238
+ "set-better-auth-cookie":
239
+ "better-auth.session_token=new-token; Max-Age=3600",
240
+ }),
241
+ },
242
+ } as any);
243
+
244
+ expect(notify).toHaveBeenCalledWith("$sessionSignal");
245
+ });
246
+
247
+ it("does not notify when session token value is unchanged", async () => {
248
+ // Pre-populate storage with existing token
249
+ storage.set(
250
+ cookieName,
251
+ JSON.stringify({
252
+ "better-auth.session_token": {
253
+ value: "same-token",
254
+ expires: new Date(Date.now() + 3600000).toISOString(),
255
+ },
256
+ })
257
+ );
258
+
259
+ const { onSuccess, notify } = getOnSuccessHookWithStore();
260
+ await onSuccess({
261
+ data: null,
262
+ request: { url: new URL("https://example.com/api/auth/get-session") },
263
+ response: {
264
+ headers: new Headers({
265
+ "set-better-auth-cookie":
266
+ "better-auth.session_token=same-token; Max-Age=3600",
267
+ }),
268
+ },
269
+ } as any);
270
+
271
+ expect(notify).not.toHaveBeenCalled();
272
+ });
273
+
274
+ it("notifies when session token value differs from stored", async () => {
275
+ storage.set(
276
+ cookieName,
277
+ JSON.stringify({
278
+ "better-auth.session_token": {
279
+ value: "old-token",
280
+ expires: new Date(Date.now() + 3600000).toISOString(),
281
+ },
282
+ })
283
+ );
284
+
285
+ const { onSuccess, notify } = getOnSuccessHookWithStore();
286
+ await onSuccess({
287
+ data: null,
288
+ request: { url: new URL("https://example.com/api/auth/get-session") },
289
+ response: {
290
+ headers: new Headers({
291
+ "set-better-auth-cookie":
292
+ "better-auth.session_token=new-token; Max-Age=3600",
293
+ }),
294
+ },
295
+ } as any);
296
+
297
+ expect(notify).toHaveBeenCalledWith("$sessionSignal");
298
+ });
299
+
300
+ it("does not notify for non-session cookies", async () => {
301
+ const { onSuccess, notify } = getOnSuccessHookWithStore();
302
+ await onSuccess({
303
+ data: null,
304
+ request: { url: new URL("https://example.com/api/auth/something") },
305
+ response: {
306
+ headers: new Headers({
307
+ "set-better-auth-cookie": "other_cookie=value; Max-Age=3600",
308
+ }),
309
+ },
310
+ } as any);
311
+
312
+ expect(notify).not.toHaveBeenCalled();
313
+ });
314
+ });
315
+ });
@@ -37,7 +37,7 @@ export function parseSetCookieHeader(
37
37
 
38
38
  interface StoredCookie {
39
39
  value: string;
40
- expires: Date | null;
40
+ expires: string | null;
41
41
  }
42
42
 
43
43
  export function getSetCookie(header: string, prevCookie?: string) {
@@ -53,7 +53,7 @@ export function getSetCookie(header: string, prevCookie?: string) {
53
53
  : null;
54
54
  toSetCookie[key] = {
55
55
  value: cookie["value"],
56
- expires,
56
+ expires: expires ? expires.toISOString() : null,
57
57
  };
58
58
  });
59
59
  if (prevCookie) {
@@ -78,7 +78,7 @@ export function getCookie(cookie: string) {
78
78
  // noop
79
79
  }
80
80
  const toSend = Object.entries(parsed).reduce((acc, [key, value]) => {
81
- if (value.expires && value.expires < new Date()) {
81
+ if (value.expires && new Date(value.expires) < new Date()) {
82
82
  return acc;
83
83
  }
84
84
  return `${acc}; ${key}=${value.value}`;
@@ -149,9 +149,16 @@ export const crossDomainClient = (
149
149
  * const sessionData = client.getSessionData();
150
150
  * ```
151
151
  */
152
- getSessionData: () => {
152
+ getSessionData: (): Record<string, unknown> | null => {
153
153
  const sessionData = storage?.getItem(localCacheName);
154
- return sessionData ? JSON.parse(sessionData) : null;
154
+ if (!sessionData) return null;
155
+ try {
156
+ const parsed = JSON.parse(sessionData);
157
+ if (parsed && typeof parsed === "object" && Object.keys(parsed).length === 0) return null;
158
+ return parsed;
159
+ } catch {
160
+ return null;
161
+ }
155
162
  },
156
163
  };
157
164
  },
@@ -174,9 +181,27 @@ export const crossDomainClient = (
174
181
  prevCookie ?? undefined
175
182
  );
176
183
  await storage.setItem(cookieName, toSetCookie);
177
- // Only notify on session cookie set
184
+ // Only notify when the session token value actually changed.
185
+ // max-age recalculation with Date.now() causes the stored
186
+ // cookie JSON to always differ, so comparing values directly
187
+ // prevents infinite get-session polling loops.
178
188
  if (setCookie.includes(".session_token=")) {
179
- store?.notify("$sessionSignal");
189
+ const parsed = parseSetCookieHeader(setCookie);
190
+ let prevParsed: Record<string, StoredCookie> = {};
191
+ try {
192
+ prevParsed = JSON.parse(prevCookie || "{}");
193
+ } catch {
194
+ // noop
195
+ }
196
+ const tokenKey = [...parsed.keys()].find((k) =>
197
+ k.includes("session_token")
198
+ );
199
+ if (
200
+ tokenKey &&
201
+ prevParsed[tokenKey]?.value !== parsed.get(tokenKey)?.value
202
+ ) {
203
+ store?.notify("$sessionSignal");
204
+ }
180
205
  }
181
206
  }
182
207
 
@@ -186,6 +211,9 @@ export const crossDomainClient = (
186
211
  ) {
187
212
  const data = context.data;
188
213
  storage.setItem(localCacheName, JSON.stringify(data));
214
+ if (data === null) {
215
+ storage.setItem(cookieName, "{}");
216
+ }
189
217
  }
190
218
  },
191
219
  },
@@ -12,11 +12,13 @@ export const crossDomain = ({ siteUrl }: { siteUrl: string }) => {
12
12
  const oneTimeToken = oneTimeTokenPlugin();
13
13
 
14
14
  const rewriteCallbackURL = (callbackURL?: string) => {
15
- if (callbackURL && !callbackURL.startsWith("/")) {
15
+ if (!callbackURL) {
16
16
  return callbackURL;
17
17
  }
18
- const relativeCallbackURL = callbackURL || "/";
19
- return new URL(relativeCallbackURL, siteUrl).toString();
18
+ if (!callbackURL.startsWith("/")) {
19
+ return callbackURL;
20
+ }
21
+ return new URL(callbackURL, siteUrl).toString();
20
22
  };
21
23
 
22
24
  const isExpoNative = (ctx: { headers?: Headers }) => {
@@ -54,9 +56,10 @@ export const crossDomain = ({ siteUrl }: { siteUrl: string }) => {
54
56
  matcher(ctx) {
55
57
  return (
56
58
  Boolean(
57
- ctx.request?.headers.get("better-auth-cookie") ||
58
- ctx.headers?.get("better-auth-cookie")
59
- ) && !isExpoNative(ctx)
59
+ ctx.request?.headers.has("better-auth-cookie") ||
60
+ ctx.headers?.has("better-auth-cookie")
61
+ ) &&
62
+ !isExpoNative(ctx)
60
63
  );
61
64
  },
62
65
  handler: createAuthMiddleware(async (ctx) => {
@@ -129,7 +132,13 @@ export const crossDomain = ({ siteUrl }: { siteUrl: string }) => {
129
132
  after: [
130
133
  {
131
134
  matcher(ctx) {
132
- return !isExpoNative(ctx);
135
+ return (
136
+ Boolean(
137
+ ctx.request?.headers.has("better-auth-cookie") ||
138
+ ctx.headers?.has("better-auth-cookie")
139
+ ) &&
140
+ !isExpoNative(ctx)
141
+ );
133
142
  },
134
143
  handler: createAuthMiddleware(async (ctx) => {
135
144
  const setCookie = ctx.context.responseHeaders?.get("set-cookie");
@@ -1,5 +1,5 @@
1
1
  import type { PropsWithChildren, ReactNode } from "react";
2
- import { Component, useCallback, useEffect, useMemo, useState } from "react";
2
+ import { Component, useCallback, useEffect, useMemo, useRef, useState } from "react";
3
3
  import type { AuthTokenFetcher } from "convex/browser";
4
4
  import {
5
5
  Authenticated,
@@ -64,12 +64,16 @@ export function ConvexBetterAuthProvider({
64
64
  const useBetterAuth = useUseAuthFromBetterAuth(authClient, initialToken);
65
65
  useEffect(() => {
66
66
  (async () => {
67
- const url = new URL(window.location?.href);
67
+ if (typeof window === "undefined" || !window.location?.href) {
68
+ return;
69
+ }
70
+ const url = new URL(window.location.href);
68
71
  const token = url.searchParams.get("ott");
69
72
  if (token) {
70
73
  const authClientWithCrossDomain =
71
74
  authClient as AuthClientWithPlugins<PluginsWithCrossDomain>;
72
75
  url.searchParams.delete("ott");
76
+ window.history.replaceState({}, "", url);
73
77
  const result =
74
78
  await authClientWithCrossDomain.crossDomain.oneTimeToken.verify({
75
79
  token,
@@ -85,7 +89,6 @@ export function ConvexBetterAuthProvider({
85
89
  });
86
90
  authClientWithCrossDomain.updateSession();
87
91
  }
88
- window.history.replaceState({}, "", url);
89
92
  }
90
93
  })();
91
94
  }, [authClient]);
@@ -103,8 +106,9 @@ function useUseAuthFromBetterAuth(
103
106
  initialToken?: string | null
104
107
  ) {
105
108
  const [cachedToken, setCachedToken] = useState<string | null>(
106
- initialTokenUsed ? (initialToken ?? null) : null
109
+ initialTokenUsed ? null : (initialToken ?? null)
107
110
  );
111
+ const pendingTokenRef = useRef<Promise<string | null> | null>(null);
108
112
  useEffect(() => {
109
113
  if (!initialTokenUsed) {
110
114
  initialTokenUsed = true;
@@ -129,15 +133,24 @@ function useUseAuthFromBetterAuth(
129
133
  if (cachedToken && !forceRefreshToken) {
130
134
  return cachedToken;
131
135
  }
132
- try {
133
- const { data } = await authClient.convex.token();
134
- const token = data?.token || null;
135
- setCachedToken(token);
136
- return token;
137
- } catch {
138
- setCachedToken(null);
139
- return null;
136
+ if (!forceRefreshToken && pendingTokenRef.current) {
137
+ return pendingTokenRef.current;
140
138
  }
139
+ pendingTokenRef.current = authClient.convex
140
+ .token({ fetchOptions: { throw: false } })
141
+ .then(({ data }) => {
142
+ const token = data?.token || null;
143
+ setCachedToken(token);
144
+ return token;
145
+ })
146
+ .catch(() => {
147
+ setCachedToken(null);
148
+ return null;
149
+ })
150
+ .finally(() => {
151
+ pendingTokenRef.current = null;
152
+ });
153
+ return pendingTokenRef.current;
141
154
  },
142
155
  // Build a new fetchAccessToken to trigger setAuth() whenever the
143
156
  // session changes.
@@ -146,12 +159,12 @@ function useUseAuthFromBetterAuth(
146
159
  );
147
160
  return useMemo(
148
161
  () => ({
149
- isLoading: isSessionPending,
150
- isAuthenticated: session !== null,
162
+ isLoading: isSessionPending && !cachedToken,
163
+ isAuthenticated: Boolean(session?.session) || cachedToken !== null,
151
164
  fetchAccessToken,
152
165
  }),
153
166
  // eslint-disable-next-line react-hooks/exhaustive-deps
154
- [isSessionPending, sessionId, fetchAccessToken]
167
+ [isSessionPending, sessionId, fetchAccessToken, cachedToken]
155
168
  );
156
169
  },
157
170
  [authClient]
@@ -86,7 +86,10 @@ export const convexBetterAuthReactStart = (
86
86
  const cachedGetToken = cache(async (opts: GetTokenOptions) => {
87
87
  const { getRequestHeaders } = await import("@tanstack/react-start/server");
88
88
  const headers = getRequestHeaders();
89
- return getToken(siteUrl, headers, opts);
89
+ const mutableHeaders = new Headers(headers);
90
+ mutableHeaders.delete("content-length");
91
+ mutableHeaders.delete("transfer-encoding");
92
+ return getToken(siteUrl, mutableHeaders, opts);
90
93
  });
91
94
 
92
95
  const callWithToken = async <