@convex-dev/better-auth 0.11.5 → 0.12.1

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 (103) hide show
  1. package/dist/auth-options.d.ts.map +1 -1
  2. package/dist/auth-options.js +1 -0
  3. package/dist/auth-options.js.map +1 -1
  4. package/dist/client/adapter-utils.d.ts +8 -2
  5. package/dist/client/adapter-utils.d.ts.map +1 -1
  6. package/dist/client/adapter-utils.js +8 -3
  7. package/dist/client/adapter-utils.js.map +1 -1
  8. package/dist/client/adapter.d.ts +0 -1
  9. package/dist/client/adapter.d.ts.map +1 -1
  10. package/dist/client/adapter.js +5 -0
  11. package/dist/client/adapter.js.map +1 -1
  12. package/dist/client/create-api.d.ts +6 -0
  13. package/dist/client/create-api.d.ts.map +1 -1
  14. package/dist/client/create-api.js +1 -0
  15. package/dist/client/create-api.js.map +1 -1
  16. package/dist/client/create-client.d.ts +0 -2
  17. package/dist/client/create-client.d.ts.map +1 -1
  18. package/dist/client/create-client.js.map +1 -1
  19. package/dist/client/create-schema.d.ts.map +1 -1
  20. package/dist/client/create-schema.js +38 -8
  21. package/dist/client/create-schema.js.map +1 -1
  22. package/dist/component/_generated/api.d.ts +0 -2
  23. package/dist/component/_generated/api.d.ts.map +1 -1
  24. package/dist/component/_generated/api.js.map +1 -1
  25. package/dist/component/_generated/component.d.ts +543 -31
  26. package/dist/component/_generated/component.d.ts.map +1 -1
  27. package/dist/component/adapter.d.ts +6 -0
  28. package/dist/component/adapter.d.ts.map +1 -1
  29. package/dist/component/schema.d.ts +9 -4
  30. package/dist/component/schema.d.ts.map +1 -1
  31. package/dist/component/schema.js +4 -2
  32. package/dist/component/schema.js.map +1 -1
  33. package/dist/component/testProfiles/adapterAdditionalFields.d.ts +6 -0
  34. package/dist/component/testProfiles/adapterAdditionalFields.d.ts.map +1 -1
  35. package/dist/component/testProfiles/adapterOrganizationJoins.d.ts +6 -0
  36. package/dist/component/testProfiles/adapterOrganizationJoins.d.ts.map +1 -1
  37. package/dist/component/testProfiles/adapterPluginTable.d.ts +6 -0
  38. package/dist/component/testProfiles/adapterPluginTable.d.ts.map +1 -1
  39. package/dist/component/testProfiles/adapterRenameField.d.ts +6 -0
  40. package/dist/component/testProfiles/adapterRenameField.d.ts.map +1 -1
  41. package/dist/component/testProfiles/adapterRenameUserCustom.d.ts +6 -0
  42. package/dist/component/testProfiles/adapterRenameUserCustom.d.ts.map +1 -1
  43. package/dist/component/testProfiles/adapterRenameUserTable.d.ts +6 -0
  44. package/dist/component/testProfiles/adapterRenameUserTable.d.ts.map +1 -1
  45. package/dist/component/testProfiles/schema.profile-additional-fields.d.ts +3 -1
  46. package/dist/component/testProfiles/schema.profile-additional-fields.d.ts.map +1 -1
  47. package/dist/component/testProfiles/schema.profile-plugin-table.d.ts +3 -1
  48. package/dist/component/testProfiles/schema.profile-plugin-table.d.ts.map +1 -1
  49. package/dist/plugins/convex/client.d.ts +1 -0
  50. package/dist/plugins/convex/client.d.ts.map +1 -1
  51. package/dist/plugins/convex/client.js +2 -0
  52. package/dist/plugins/convex/client.js.map +1 -1
  53. package/dist/plugins/convex/index.d.ts +2 -1
  54. package/dist/plugins/convex/index.d.ts.map +1 -1
  55. package/dist/plugins/convex/index.js +15 -2
  56. package/dist/plugins/convex/index.js.map +1 -1
  57. package/dist/plugins/cross-domain/client.d.ts +1 -12
  58. package/dist/plugins/cross-domain/client.d.ts.map +1 -1
  59. package/dist/plugins/cross-domain/client.js +35 -27
  60. package/dist/plugins/cross-domain/client.js.map +1 -1
  61. package/dist/plugins/cross-domain/index.d.ts +1 -0
  62. package/dist/plugins/cross-domain/index.d.ts.map +1 -1
  63. package/dist/plugins/cross-domain/index.js +15 -4
  64. package/dist/plugins/cross-domain/index.js.map +1 -1
  65. package/dist/test/adapter-factory/basic.d.ts +4 -0
  66. package/dist/test/adapter-factory/basic.d.ts.map +1 -1
  67. package/dist/test/adapter-factory/convex-custom.d.ts +1 -1
  68. package/dist/test/adapter-factory/convex-custom.d.ts.map +1 -1
  69. package/dist/test/adapter-factory/convex-custom.js +20 -0
  70. package/dist/test/adapter-factory/convex-custom.js.map +1 -1
  71. package/dist/utils/index.d.ts +1 -0
  72. package/dist/utils/index.d.ts.map +1 -1
  73. package/dist/utils/index.js +6 -1
  74. package/dist/utils/index.js.map +1 -1
  75. package/dist/version.d.ts +2 -0
  76. package/dist/version.d.ts.map +1 -0
  77. package/dist/version.js +2 -0
  78. package/dist/version.js.map +1 -0
  79. package/package.json +6 -6
  80. package/src/auth-options.ts +1 -0
  81. package/src/client/adapter-utils.ts +13 -5
  82. package/src/client/adapter.test.ts +225 -2
  83. package/src/client/adapter.ts +7 -1
  84. package/src/client/create-api.ts +3 -0
  85. package/src/client/create-client.ts +0 -2
  86. package/src/client/create-schema.ts +46 -8
  87. package/src/component/_generated/api.ts +0 -2
  88. package/src/component/_generated/component.ts +718 -38
  89. package/src/component/schema.ts +5 -3
  90. package/src/plugins/convex/client.ts +2 -0
  91. package/src/plugins/convex/index.ts +16 -3
  92. package/src/plugins/cross-domain/client.test.ts +58 -28
  93. package/src/plugins/cross-domain/client.ts +44 -45
  94. package/src/plugins/cross-domain/index.test.ts +129 -51
  95. package/src/plugins/cross-domain/index.ts +20 -7
  96. package/src/test/adapter-factory/convex-custom.ts +23 -0
  97. package/src/utils/index.ts +7 -1
  98. package/src/version.ts +1 -0
  99. package/dist/component/adapterTest.d.ts +0 -3
  100. package/dist/component/adapterTest.d.ts.map +0 -1
  101. package/dist/component/adapterTest.js +0 -202
  102. package/dist/component/adapterTest.js.map +0 -1
  103. package/src/component/adapterTest.ts +0 -275
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * This file is auto-generated. Do not edit this file manually.
3
- * To regenerate the schema, run:
4
- * `npx @better-auth/cli generate --output src/component/schema.ts -y`
5
- *
3
+ * To regenerate the schema, from your project root:
4
+ *
5
+ * npx auth generate --output src/component/schema.ts
6
+ *
6
7
  * To customize the schema, generate to an alternate file and import
7
8
  * the table definitions to your schema file. See
8
9
  * https://labs.convex.dev/better-auth/features/local-install#adding-custom-indexes.
@@ -76,6 +77,7 @@ export const tables = {
76
77
  secret: v.string(),
77
78
  backupCodes: v.string(),
78
79
  userId: v.string(),
80
+ verified: v.optional(v.union(v.null(), v.boolean())),
79
81
  })
80
82
  .index("userId", ["userId"]),
81
83
  oauthApplication: defineTable({
@@ -1,9 +1,11 @@
1
1
  import type { BetterAuthClientPlugin } from "better-auth/client";
2
2
  import type { convex } from "./index.js";
3
+ import { VERSION } from "../../version.js";
3
4
 
4
5
  export const convexClient = () => {
5
6
  return {
6
7
  id: "convex",
8
+ version: VERSION,
7
9
  $InferServerPlugin: {} as ReturnType<typeof convex>,
8
10
  } satisfies BetterAuthClientPlugin;
9
11
  };
@@ -11,6 +11,7 @@ import type { JwtOptions, Jwk } from "better-auth/plugins/jwt";
11
11
  import { oidcProvider as oidcProviderPlugin } from "better-auth/plugins/oidc-provider";
12
12
  import { omit } from "convex-helpers";
13
13
  import type { AuthConfig, AuthProvider } from "convex/server";
14
+ import { VERSION } from "../../version.js";
14
15
 
15
16
  export const JWT_COOKIE_NAME = "convex_jwt";
16
17
 
@@ -164,7 +165,7 @@ export const convex = (opts: {
164
165
  * Handles error that occurs when existing JWKS key does not match configured
165
166
  * algorithm, which will be common for 0.10 upgrades switching from EdDSA to RS256.
166
167
  *
167
- * @default true
168
+ * @default false
168
169
  */
169
170
  jwksRotateOnTokenGenerationError?: boolean;
170
171
  /**
@@ -181,6 +182,7 @@ export const convex = (opts: {
181
182
  issuer: `${process.env.CONVEX_SITE_URL}`,
182
183
  jwks_uri: `${process.env.CONVEX_SITE_URL}${opts.options?.basePath ?? "/api/auth"}/convex/jwks`,
183
184
  },
185
+ __skipDeprecationWarning: true,
184
186
  });
185
187
  const providerConfig = parseAuthConfig(opts.authConfig, opts);
186
188
 
@@ -189,9 +191,9 @@ export const convex = (opts: {
189
191
  issuer: `${process.env.CONVEX_SITE_URL}`,
190
192
  audience: "convex",
191
193
  expirationTime: `${jwtExpirationSeconds}s`,
192
- definePayload: ({ user, session }) => ({
194
+ definePayload: async ({ user, session }) => ({
193
195
  ...(opts.jwt?.definePayload
194
- ? opts.jwt.definePayload({ user, session })
196
+ ? await opts.jwt.definePayload({ user, session })
195
197
  : omit(user, ["id", "image"])),
196
198
  sessionId: session.id,
197
199
  iat: Math.floor(new Date().getTime() / 1000),
@@ -253,6 +255,7 @@ export const convex = (opts: {
253
255
 
254
256
  return {
255
257
  id: "convex",
258
+ version: VERSION,
256
259
  init: (ctx) => {
257
260
  const { options, logger: _logger } = ctx;
258
261
  if (options.basePath !== "/api/auth" && !opts.options?.basePath) {
@@ -337,6 +340,7 @@ export const convex = (opts: {
337
340
  ...ctx,
338
341
  headers: {},
339
342
  method: "GET",
343
+ asResponse: false,
340
344
  returnHeaders: false,
341
345
  returnStatus: false,
342
346
  });
@@ -382,6 +386,7 @@ export const convex = (opts: {
382
386
  async (ctx) => {
383
387
  const response = await oidcProvider.endpoints.getOpenIdConfig({
384
388
  ...ctx,
389
+ asResponse: false,
385
390
  returnHeaders: false,
386
391
  returnStatus: false,
387
392
  });
@@ -478,6 +483,7 @@ export const convex = (opts: {
478
483
  async (ctx) => {
479
484
  const response = await jwt.endpoints.getJwks({
480
485
  ...ctx,
486
+ asResponse: false,
481
487
  returnHeaders: false,
482
488
  returnStatus: false,
483
489
  });
@@ -504,6 +510,9 @@ export const convex = (opts: {
504
510
  await jwtPlugin(jwtOptions).endpoints.getJwks({
505
511
  ...ctx,
506
512
  method: "GET",
513
+ asResponse: false,
514
+ returnHeaders: false,
515
+ returnStatus: false,
507
516
  });
508
517
  const jwks: any[] = await ctx.context.adapter.findMany({
509
518
  model: "jwks",
@@ -542,6 +551,9 @@ export const convex = (opts: {
542
551
  await jwtPlugin(jwtOptions).endpoints.getJwks({
543
552
  ...ctx,
544
553
  method: "GET",
554
+ asResponse: false,
555
+ returnHeaders: false,
556
+ returnStatus: false,
545
557
  });
546
558
  const jwks: any[] = await ctx.context.adapter.findMany({
547
559
  model: "jwks",
@@ -588,6 +600,7 @@ export const convex = (opts: {
588
600
  const runEndpoint = async () => {
589
601
  const response = await jwt.endpoints.getToken({
590
602
  ...ctx,
603
+ asResponse: false,
591
604
  returnHeaders: false,
592
605
  returnStatus: false,
593
606
  });
@@ -1,29 +1,23 @@
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
- });
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { crossDomainClient, getCookie, getSetCookie } from "./client.js";
10
3
 
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
- });
4
+ describe("getSetCookie", () => {
5
+ it("stores cookies from RFC-1123 Expires headers without splitting the date", () => {
6
+ const header =
7
+ "session_token=abc; Path=/; Expires=Wed, 21 Oct 2030 07:28:00 GMT, other_cookie=xyz; Path=/";
17
8
 
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");
9
+ const result = JSON.parse(getSetCookie(header));
10
+
11
+ expect(result.session_token).toEqual({
12
+ value: "abc",
13
+ expires: "2030-10-21T07:28:00.000Z",
14
+ });
15
+ expect(result.other_cookie).toEqual({
16
+ value: "xyz",
17
+ expires: null,
18
+ });
23
19
  });
24
- });
25
20
 
26
- describe("getSetCookie", () => {
27
21
  it("stores expires as ISO string", () => {
28
22
  const header = "session_token=abc; Max-Age=3600";
29
23
  const result = JSON.parse(getSetCookie(header));
@@ -66,7 +60,10 @@ describe("getSetCookie", () => {
66
60
  describe("getCookie", () => {
67
61
  it("returns cookie string for valid cookies", () => {
68
62
  const stored = JSON.stringify({
69
- session: { value: "abc", expires: new Date(Date.now() + 60000).toISOString() },
63
+ session: {
64
+ value: "abc",
65
+ expires: new Date(Date.now() + 60000).toISOString(),
66
+ },
70
67
  });
71
68
  const result = getCookie(stored);
72
69
  expect(result).toContain("session=abc");
@@ -74,8 +71,14 @@ describe("getCookie", () => {
74
71
 
75
72
  it("filters out expired cookies", () => {
76
73
  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() },
74
+ expired: {
75
+ value: "old",
76
+ expires: new Date(Date.now() - 60000).toISOString(),
77
+ },
78
+ valid: {
79
+ value: "new",
80
+ expires: new Date(Date.now() + 60000).toISOString(),
81
+ },
79
82
  });
80
83
  const result = getCookie(stored);
81
84
  expect(result).not.toContain("expired=old");
@@ -112,7 +115,10 @@ describe("getCookie", () => {
112
115
 
113
116
  describe("crossDomainClient", () => {
114
117
  let storage: Map<string, string>;
115
- let mockStorage: { getItem: (key: string) => string | null; setItem: (key: string, value: string) => void };
118
+ let mockStorage: {
119
+ getItem: (key: string) => string | null;
120
+ setItem: (key: string, value: string) => void;
121
+ };
116
122
  const cookieName = "better-auth_cookie";
117
123
  const localCacheName = "better-auth_session_data";
118
124
 
@@ -120,7 +126,9 @@ describe("crossDomainClient", () => {
120
126
  storage = new Map<string, string>();
121
127
  mockStorage = {
122
128
  getItem: (key) => storage.get(key) ?? null,
123
- setItem: (key, value) => { storage.set(key, value); },
129
+ setItem: (key, value) => {
130
+ storage.set(key, value);
131
+ },
124
132
  };
125
133
  });
126
134
 
@@ -183,7 +191,7 @@ describe("crossDomainClient", () => {
183
191
  });
184
192
 
185
193
  describe("onSuccess handler", () => {
186
- it("clears cookies when get-session returns null", async () => {
194
+ it("clears session cookies when get-session returns null", async () => {
187
195
  storage.set(cookieName, JSON.stringify({
188
196
  "better-auth.session_token": { value: "stale", expires: null },
189
197
  }));
@@ -198,6 +206,28 @@ describe("crossDomainClient", () => {
198
206
  expect(storage.get(cookieName)).toBe("{}");
199
207
  });
200
208
 
209
+ it("preserves two_factor cookie when get-session returns null", async () => {
210
+ storage.set(cookieName, JSON.stringify({
211
+ "better-auth.session_token": { value: "stale", expires: null },
212
+ "better-auth.session_data": { value: "data", expires: null },
213
+ "better-auth.convex_jwt": { value: "jwt", expires: null },
214
+ "better-auth.two_factor": { value: "2fa-challenge-token", expires: null },
215
+ }));
216
+
217
+ const onSuccess = getOnSuccessHook();
218
+ await onSuccess({
219
+ data: null,
220
+ request: { url: new URL("https://example.com/api/auth/get-session") },
221
+ response: { headers: new Headers() },
222
+ } as any);
223
+
224
+ const result = JSON.parse(storage.get(cookieName)!);
225
+ expect(result["better-auth.two_factor"]).toEqual({ value: "2fa-challenge-token", expires: null });
226
+ expect(result["better-auth.session_token"]).toBeUndefined();
227
+ expect(result["better-auth.session_data"]).toBeUndefined();
228
+ expect(result["better-auth.convex_jwt"]).toBeUndefined();
229
+ });
230
+
201
231
  it("preserves cookies when get-session returns data", async () => {
202
232
  const existingCookies = JSON.stringify({
203
233
  "better-auth.session_token": { value: "valid", expires: null },
@@ -1,39 +1,8 @@
1
1
  import type { BetterAuthClientPlugin, ClientStore } from "better-auth";
2
+ import { parseSetCookieHeader } from "better-auth/cookies";
2
3
  import type { BetterFetchOption } from "@better-fetch/fetch";
3
4
  import type { crossDomain } from "./index.js";
4
-
5
- interface CookieAttributes {
6
- value: string;
7
- expires?: Date;
8
- "max-age"?: number;
9
- domain?: string;
10
- path?: string;
11
- secure?: boolean;
12
- httpOnly?: boolean;
13
- sameSite?: "Strict" | "Lax" | "None";
14
- }
15
-
16
- export function parseSetCookieHeader(
17
- header: string
18
- ): Map<string, CookieAttributes> {
19
- const cookieMap = new Map<string, CookieAttributes>();
20
- const cookies = header.split(", ");
21
- cookies.forEach((cookie) => {
22
- const [nameValue, ...attributes] = cookie.split("; ");
23
- const [name, value] = nameValue.split("=");
24
-
25
- const cookieObj: CookieAttributes = { value };
26
-
27
- attributes.forEach((attr) => {
28
- const [attrName, attrValue] = attr.split("=");
29
- cookieObj[attrName.toLowerCase() as "value"] = attrValue;
30
- });
31
-
32
- cookieMap.set(name, cookieObj);
33
- });
34
-
35
- return cookieMap;
36
- }
5
+ import { VERSION } from "../../version.js";
37
6
 
38
7
  interface StoredCookie {
39
8
  value: string;
@@ -77,13 +46,12 @@ export function getCookie(cookie: string) {
77
46
  } catch {
78
47
  // noop
79
48
  }
80
- const toSend = Object.entries(parsed).reduce((acc, [key, value]) => {
81
- if (value.expires && new Date(value.expires) < new Date()) {
82
- return acc;
83
- }
84
- return `${acc}; ${key}=${value.value}`;
85
- }, "");
86
- return toSend;
49
+ return Object.entries(parsed)
50
+ .filter(
51
+ ([, value]) => !value.expires || new Date(value.expires) >= new Date()
52
+ )
53
+ .map(([key, value]) => `${key}=${value.value}`)
54
+ .join("; ");
87
55
  }
88
56
 
89
57
  export const crossDomainClient = (
@@ -104,6 +72,7 @@ export const crossDomainClient = (
104
72
 
105
73
  return {
106
74
  id: "cross-domain",
75
+ version: VERSION,
107
76
  $InferServerPlugin: {} as ReturnType<typeof crossDomain>,
108
77
  getActions(_, $store) {
109
78
  store = $store;
@@ -154,7 +123,13 @@ export const crossDomainClient = (
154
123
  if (!sessionData) return null;
155
124
  try {
156
125
  const parsed = JSON.parse(sessionData);
157
- if (parsed && typeof parsed === "object" && Object.keys(parsed).length === 0) return null;
126
+ if (
127
+ parsed &&
128
+ typeof parsed === "object" &&
129
+ Object.keys(parsed).length === 0
130
+ ) {
131
+ return null;
132
+ }
158
133
  return parsed;
159
134
  } catch {
160
135
  return null;
@@ -164,8 +139,8 @@ export const crossDomainClient = (
164
139
  },
165
140
  fetchPlugins: [
166
141
  {
167
- id: "convex",
168
- name: "Convex",
142
+ id: "cross-domain",
143
+ name: "Cross Domain",
169
144
  hooks: {
170
145
  async onSuccess(context) {
171
146
  if (!storage) {
@@ -212,7 +187,31 @@ export const crossDomainClient = (
212
187
  const data = context.data;
213
188
  storage.setItem(localCacheName, JSON.stringify(data));
214
189
  if (data === null) {
215
- storage.setItem(cookieName, "{}");
190
+ // Preserve non-session cookies (e.g. two_factor) when
191
+ // get-session returns null during 2FA pending state.
192
+ // Previously this unconditionally set cookieName to "{}",
193
+ // which wiped the two_factor challenge token needed for
194
+ // verifyTotp in cross-domain setups.
195
+ const prev = storage.getItem(cookieName);
196
+ try {
197
+ const parsed = JSON.parse(prev || "{}") as Record<
198
+ string,
199
+ unknown
200
+ >;
201
+ const preserved: Record<string, unknown> = {};
202
+ for (const [key, val] of Object.entries(parsed)) {
203
+ if (
204
+ !key.includes("session_token") &&
205
+ !key.includes("session_data") &&
206
+ !key.includes("convex_jwt")
207
+ ) {
208
+ preserved[key] = val;
209
+ }
210
+ }
211
+ storage.setItem(cookieName, JSON.stringify(preserved));
212
+ } catch {
213
+ storage.setItem(cookieName, "{}");
214
+ }
216
215
  }
217
216
  }
218
217
  },
@@ -239,7 +238,7 @@ export const crossDomainClient = (
239
238
  error: null,
240
239
  isPending: false,
241
240
  });
242
- storage.setItem(localCacheName, "{}");
241
+ await storage.setItem(localCacheName, "{}");
243
242
  }
244
243
  return {
245
244
  url,
@@ -1,67 +1,145 @@
1
1
  import { describe, expect, it } from "vitest";
2
+ import { betterAuth } from "better-auth/minimal";
3
+ import { memoryAdapter } from "better-auth/adapters/memory";
4
+ import type { MemoryDB } from "better-auth/adapters/memory";
5
+ import { genericOAuth } from "better-auth/plugins";
6
+ import { magicLink } from "better-auth/plugins/magic-link";
2
7
  import { crossDomain } from "./index.js";
3
8
 
4
- const getPostRewriteMatcher = () => {
5
- const plugin = crossDomain({ siteUrl: "https://example.com" });
6
- const matcher = plugin.hooks?.before?.[2]?.matcher;
7
- if (!matcher) {
8
- throw new Error("expected cross-domain POST rewrite matcher");
9
- }
10
- return matcher;
11
- };
9
+ const SITE_URL = "https://myapp.example.com";
10
+ const AUTH_BASE_URL = "http://localhost:3000";
11
+ const BASE_PATH = "/api/auth";
12
12
 
13
- describe("crossDomain POST rewrite matcher", () => {
14
- it("matches POST requests regardless of route", () => {
15
- const matcher = getPostRewriteMatcher();
16
- type MatcherContext = Parameters<typeof matcher>[0];
13
+ describe("crossDomain plugin", async () => {
14
+ let capturedMagicLinkUrl = "";
17
15
 
18
- const knownPathCtx = {
19
- method: "POST",
20
- path: "/sign-in/email",
21
- headers: new Headers(),
22
- } satisfies Partial<MatcherContext>;
23
- const unknownPathCtx = {
24
- method: "POST",
25
- path: "/custom-endpoint",
26
- headers: new Headers(),
27
- } satisfies Partial<MatcherContext>;
16
+ const db: MemoryDB = {
17
+ user: [],
18
+ session: [],
19
+ account: [],
20
+ verification: [],
21
+ };
28
22
 
29
- expect(matcher(knownPathCtx as MatcherContext)).toBe(true);
30
- expect(matcher(unknownPathCtx as MatcherContext)).toBe(true);
23
+ const auth = betterAuth({
24
+ baseURL: AUTH_BASE_URL,
25
+ basePath: BASE_PATH,
26
+ secret: "test-secret-at-least-thirty-two-characters-long",
27
+ database: memoryAdapter(db),
28
+ emailAndPassword: {
29
+ enabled: true,
30
+ requireEmailVerification: false,
31
+ },
32
+ plugins: [
33
+ magicLink({
34
+ sendMagicLink: async ({ url }) => {
35
+ capturedMagicLinkUrl = url;
36
+ },
37
+ }),
38
+ genericOAuth({
39
+ config: [
40
+ {
41
+ providerId: "example-oauth",
42
+ clientId: "test-client-id",
43
+ clientSecret: "test-client-secret",
44
+ authorizationUrl: "https://provider.example.com/oauth/authorize",
45
+ tokenUrl: "https://provider.example.com/oauth/token",
46
+ },
47
+ ],
48
+ }),
49
+ crossDomain({ siteUrl: SITE_URL }),
50
+ ],
31
51
  });
32
52
 
33
- it("rejects non-POST methods", () => {
34
- const matcher = getPostRewriteMatcher();
35
- type MatcherContext = Parameters<typeof matcher>[0];
53
+ const post = (
54
+ path: string,
55
+ body: Record<string, unknown>,
56
+ extraHeaders?: Record<string, string>
57
+ ) =>
58
+ auth.handler(
59
+ new Request(`${AUTH_BASE_URL}${BASE_PATH}${path}`, {
60
+ method: "POST",
61
+ headers: { "Content-Type": "application/json", ...extraHeaders },
62
+ body: JSON.stringify(body),
63
+ })
64
+ );
36
65
 
37
- const getSignInCtx = {
38
- method: "GET",
39
- path: "/sign-in/email",
40
- headers: new Headers(),
41
- } satisfies Partial<MatcherContext>;
42
- const optionsLinkSocialCtx = {
43
- method: "OPTIONS",
44
- path: "/link-social",
45
- headers: new Headers(),
46
- } satisfies Partial<MatcherContext>;
47
-
48
- expect(matcher(getSignInCtx as MatcherContext)).toBe(false);
49
- expect(matcher(optionsLinkSocialCtx as MatcherContext)).toBe(false);
66
+ await post("/sign-up/email", {
67
+ email: "test@example.com",
68
+ password: "testpassword123",
69
+ name: "Test User",
50
70
  });
51
71
 
52
- it("rejects expo-native requests", () => {
53
- const matcher = getPostRewriteMatcher();
54
- type MatcherContext = Parameters<typeof matcher>[0];
72
+ describe("callbackURL defaulting for magic-link", () => {
73
+ it("injects siteUrl when callbackURL is absent", async () => {
74
+ capturedMagicLinkUrl = "";
75
+ await post("/sign-in/magic-link", { email: "test@example.com" });
76
+ const url = new URL(capturedMagicLinkUrl);
77
+ expect(url.searchParams.get("callbackURL")).toBe(SITE_URL);
78
+ });
79
+
80
+ it("rewrites relative callbackURL to absolute using siteUrl", async () => {
81
+ capturedMagicLinkUrl = "";
82
+ await post("/sign-in/magic-link", {
83
+ email: "test@example.com",
84
+ callbackURL: "/dashboard",
85
+ });
86
+ const url = new URL(capturedMagicLinkUrl);
87
+ expect(url.searchParams.get("callbackURL")).toBe(`${SITE_URL}/dashboard`);
88
+ });
55
89
 
56
- const headers = new Headers();
57
- headers.set("expo-origin", "expo");
90
+ it("preserves absolute callbackURL", async () => {
91
+ capturedMagicLinkUrl = "";
92
+ await post("/sign-in/magic-link", {
93
+ email: "test@example.com",
94
+ callbackURL: "https://other.example.com/callback",
95
+ });
96
+ const url = new URL(capturedMagicLinkUrl);
97
+ expect(url.searchParams.get("callbackURL")).toBe(
98
+ "https://other.example.com/callback"
99
+ );
100
+ });
101
+ });
58
102
 
59
- const expoCtx = {
60
- method: "POST",
61
- path: "/sign-in/social",
62
- headers,
63
- } satisfies Partial<MatcherContext>;
103
+ describe("callbackURL defaulting for oauth2", () => {
104
+ it("injects siteUrl when callbackURL is absent", async () => {
105
+ const response = await post("/sign-in/oauth2", {
106
+ providerId: "example-oauth",
107
+ disableRedirect: true,
108
+ });
109
+ const { url } = (await response.json()) as { url: string };
110
+ const state = new URL(url).searchParams.get("state");
111
+ const verification = db.verification.find(
112
+ (entry) => entry.identifier === state
113
+ );
114
+ expect(verification).toBeDefined();
115
+ expect(JSON.parse(verification!.value).callbackURL).toBe(SITE_URL);
116
+ });
117
+
118
+ it("rewrites relative callbackURL to absolute using siteUrl", async () => {
119
+ const response = await post("/sign-in/oauth2", {
120
+ providerId: "example-oauth",
121
+ callbackURL: "/dashboard",
122
+ disableRedirect: true,
123
+ });
124
+ const { url } = (await response.json()) as { url: string };
125
+ const state = new URL(url).searchParams.get("state");
126
+ const verification = db.verification.find(
127
+ (entry) => entry.identifier === state
128
+ );
129
+ expect(verification).toBeDefined();
130
+ expect(JSON.parse(verification!.value).callbackURL).toBe(
131
+ `${SITE_URL}/dashboard`
132
+ );
133
+ });
134
+ });
64
135
 
65
- expect(matcher(expoCtx as MatcherContext)).toBe(false);
136
+ describe("no callbackURL injection for email sign-in", () => {
137
+ it("does not redirect when callbackURL is absent", async () => {
138
+ const response = await post("/sign-in/email", {
139
+ email: "test@example.com",
140
+ password: "testpassword123",
141
+ });
142
+ expect(response.status).not.toBe(302);
143
+ });
66
144
  });
67
145
  });
@@ -4,6 +4,7 @@ import { generateRandomString } from "better-auth/crypto";
4
4
  import { createAuthEndpoint, createAuthMiddleware } from "better-auth/api";
5
5
  import { oneTimeToken as oneTimeTokenPlugin } from "better-auth/plugins/one-time-token";
6
6
  import { z } from "zod";
7
+ import { VERSION } from "../../version.js";
7
8
 
8
9
  export const crossDomain = ({ siteUrl }: { siteUrl: string }) => {
9
10
  const oneTimeToken = oneTimeTokenPlugin();
@@ -24,6 +25,7 @@ export const crossDomain = ({ siteUrl }: { siteUrl: string }) => {
24
25
 
25
26
  return {
26
27
  id: "cross-domain",
28
+ version: VERSION,
27
29
  // TODO: remove this in the next minor release, it doesn't
28
30
  // actually affect ctx.trustedOrigins. cors allowedOrigins
29
31
  // is using it, via options.trustedOrigins, though, so it's
@@ -55,8 +57,7 @@ export const crossDomain = ({ siteUrl }: { siteUrl: string }) => {
55
57
  Boolean(
56
58
  ctx.request?.headers.has("better-auth-cookie") ||
57
59
  ctx.headers?.has("better-auth-cookie")
58
- ) &&
59
- !isExpoNative(ctx)
60
+ ) && !isExpoNative(ctx)
60
61
  );
61
62
  },
62
63
  handler: createAuthMiddleware(async (ctx) => {
@@ -85,8 +86,8 @@ export const crossDomain = ({ siteUrl }: { siteUrl: string }) => {
85
86
  matcher: (ctx) => {
86
87
  return Boolean(
87
88
  ctx.method === "GET" &&
88
- ctx.path?.startsWith("/verify-email") &&
89
- !isExpoNative(ctx)
89
+ ctx.path?.startsWith("/verify-email") &&
90
+ !isExpoNative(ctx)
90
91
  );
91
92
  },
92
93
  handler: createAuthMiddleware(async (ctx) => {
@@ -101,6 +102,18 @@ export const crossDomain = ({ siteUrl }: { siteUrl: string }) => {
101
102
  return Boolean(ctx.method === "POST" && !isExpoNative(ctx));
102
103
  },
103
104
  handler: createAuthMiddleware(async (ctx) => {
105
+ // Set callbackURL to siteUrl for redirect-triggering paths with
106
+ // no callbackURL defined.
107
+ if (
108
+ ctx.body &&
109
+ !ctx.body.callbackURL &&
110
+ (ctx.path?.startsWith("/sign-in/social") ||
111
+ ctx.path?.startsWith("/sign-in/oauth2") ||
112
+ ctx.path?.startsWith("/sign-in/magic-link") ||
113
+ ctx.path?.startsWith("/send-verification-email"))
114
+ ) {
115
+ ctx.body.callbackURL = siteUrl;
116
+ }
104
117
  if (ctx.body?.callbackURL) {
105
118
  ctx.body.callbackURL = rewriteCallbackURL(ctx.body.callbackURL);
106
119
  }
@@ -125,8 +138,7 @@ export const crossDomain = ({ siteUrl }: { siteUrl: string }) => {
125
138
  Boolean(
126
139
  ctx.request?.headers.has("better-auth-cookie") ||
127
140
  ctx.headers?.has("better-auth-cookie")
128
- ) &&
129
- !isExpoNative(ctx)
141
+ ) && !isExpoNative(ctx)
130
142
  );
131
143
  },
132
144
  handler: createAuthMiddleware(async (ctx) => {
@@ -144,7 +156,7 @@ export const crossDomain = ({ siteUrl }: { siteUrl: string }) => {
144
156
  (ctx.path?.startsWith("/callback") ||
145
157
  ctx.path?.startsWith("/oauth2/callback") ||
146
158
  ctx.path?.startsWith("/magic-link/verify")) &&
147
- !isExpoNative(ctx)
159
+ !isExpoNative(ctx)
148
160
  );
149
161
  },
150
162
  handler: createAuthMiddleware(async (ctx) => {
@@ -185,6 +197,7 @@ export const crossDomain = ({ siteUrl }: { siteUrl: string }) => {
185
197
  async (ctx) => {
186
198
  const response = await oneTimeToken.endpoints.verifyOneTimeToken({
187
199
  ...ctx,
200
+ asResponse: false,
188
201
  returnHeaders: false,
189
202
  returnStatus: false,
190
203
  });