@convex-dev/better-auth 0.11.4 → 0.12.0

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.map +1 -1
  9. package/dist/client/adapter.js +5 -0
  10. package/dist/client/adapter.js.map +1 -1
  11. package/dist/client/create-api.d.ts +6 -0
  12. package/dist/client/create-api.d.ts.map +1 -1
  13. package/dist/client/create-api.js +1 -0
  14. package/dist/client/create-api.js.map +1 -1
  15. package/dist/client/create-client.d.ts.map +1 -1
  16. package/dist/client/create-client.js +19 -2
  17. package/dist/client/create-client.js.map +1 -1
  18. package/dist/client/create-schema.d.ts.map +1 -1
  19. package/dist/client/create-schema.js +38 -8
  20. package/dist/client/create-schema.js.map +1 -1
  21. package/dist/component/_generated/component.d.ts +543 -28
  22. package/dist/component/_generated/component.d.ts.map +1 -1
  23. package/dist/component/_generated/server.d.ts.map +1 -1
  24. package/dist/component/adapter.d.ts +6 -0
  25. package/dist/component/adapter.d.ts.map +1 -1
  26. package/dist/component/schema.d.ts +9 -4
  27. package/dist/component/schema.d.ts.map +1 -1
  28. package/dist/component/schema.js +4 -2
  29. package/dist/component/schema.js.map +1 -1
  30. package/dist/component/testProfiles/adapterAdditionalFields.d.ts +6 -0
  31. package/dist/component/testProfiles/adapterAdditionalFields.d.ts.map +1 -1
  32. package/dist/component/testProfiles/adapterOrganizationJoins.d.ts +6 -0
  33. package/dist/component/testProfiles/adapterOrganizationJoins.d.ts.map +1 -1
  34. package/dist/component/testProfiles/adapterPluginTable.d.ts +6 -0
  35. package/dist/component/testProfiles/adapterPluginTable.d.ts.map +1 -1
  36. package/dist/component/testProfiles/adapterRenameField.d.ts +6 -0
  37. package/dist/component/testProfiles/adapterRenameField.d.ts.map +1 -1
  38. package/dist/component/testProfiles/adapterRenameUserCustom.d.ts +6 -0
  39. package/dist/component/testProfiles/adapterRenameUserCustom.d.ts.map +1 -1
  40. package/dist/component/testProfiles/adapterRenameUserTable.d.ts +6 -0
  41. package/dist/component/testProfiles/adapterRenameUserTable.d.ts.map +1 -1
  42. package/dist/component/testProfiles/schema.profile-additional-fields.d.ts +3 -1
  43. package/dist/component/testProfiles/schema.profile-additional-fields.d.ts.map +1 -1
  44. package/dist/component/testProfiles/schema.profile-plugin-table.d.ts +3 -1
  45. package/dist/component/testProfiles/schema.profile-plugin-table.d.ts.map +1 -1
  46. package/dist/nextjs/index.d.ts.map +1 -1
  47. package/dist/nextjs/index.js +4 -0
  48. package/dist/nextjs/index.js.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 +14 -26
  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/react-start/index.d.ts.map +1 -1
  66. package/dist/react-start/index.js +4 -0
  67. package/dist/react-start/index.js.map +1 -1
  68. package/dist/test/adapter-factory/basic.d.ts +4 -0
  69. package/dist/test/adapter-factory/basic.d.ts.map +1 -1
  70. package/dist/test/adapter-factory/convex-custom.d.ts +1 -1
  71. package/dist/test/adapter-factory/convex-custom.d.ts.map +1 -1
  72. package/dist/test/adapter-factory/convex-custom.js +20 -0
  73. package/dist/test/adapter-factory/convex-custom.js.map +1 -1
  74. package/dist/utils/index.d.ts +1 -0
  75. package/dist/utils/index.d.ts.map +1 -1
  76. package/dist/utils/index.js +5 -1
  77. package/dist/utils/index.js.map +1 -1
  78. package/dist/version.d.ts +2 -0
  79. package/dist/version.d.ts.map +1 -0
  80. package/dist/version.js +2 -0
  81. package/dist/version.js.map +1 -0
  82. package/package.json +16 -19
  83. package/src/auth-options.ts +1 -0
  84. package/src/client/adapter-utils.ts +13 -5
  85. package/src/client/adapter.ts +7 -0
  86. package/src/client/create-api.ts +3 -0
  87. package/src/client/create-client.test.ts +40 -0
  88. package/src/client/create-client.ts +25 -2
  89. package/src/client/create-schema.ts +46 -8
  90. package/src/component/_generated/component.ts +718 -35
  91. package/src/component/_generated/server.ts +0 -5
  92. package/src/component/schema.ts +5 -3
  93. package/src/nextjs/index.ts +4 -0
  94. package/src/plugins/convex/client.ts +2 -0
  95. package/src/plugins/convex/index.ts +16 -3
  96. package/src/plugins/cross-domain/client.test.ts +41 -30
  97. package/src/plugins/cross-domain/client.ts +17 -44
  98. package/src/plugins/cross-domain/index.test.ts +129 -51
  99. package/src/plugins/cross-domain/index.ts +20 -7
  100. package/src/react-start/index.ts +4 -0
  101. package/src/test/adapter-factory/convex-custom.ts +23 -0
  102. package/src/utils/index.ts +6 -1
  103. package/src/version.ts +1 -0
@@ -107,11 +107,6 @@ export const internalAction: ActionBuilder<DataModel, "internal"> =
107
107
  */
108
108
  export const httpAction: HttpActionBuilder = httpActionGeneric;
109
109
 
110
- type GenericCtx =
111
- | GenericActionCtx<DataModel>
112
- | GenericMutationCtx<DataModel>
113
- | GenericQueryCtx<DataModel>;
114
-
115
110
  /**
116
111
  * A set of services for use within Convex query functions.
117
112
  *
@@ -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({
@@ -46,6 +46,10 @@ const handler = (request: Request, siteUrl: string) => {
46
46
  const newRequest = new Request(nextUrl, request);
47
47
  newRequest.headers.set("accept-encoding", "application/json");
48
48
  newRequest.headers.set("host", new URL(siteUrl).host);
49
+ newRequest.headers.set("x-forwarded-host", requestUrl.host);
50
+ newRequest.headers.set("x-forwarded-proto", requestUrl.protocol.replace(/:$/, ""));
51
+ newRequest.headers.set("x-better-auth-forwarded-host", requestUrl.host);
52
+ newRequest.headers.set("x-better-auth-forwarded-proto", requestUrl.protocol.replace(/:$/, ""));
49
53
  return fetch(newRequest, { method: request.method, redirect: "manual" });
50
54
  };
51
55
 
@@ -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
 
@@ -184,9 +192,12 @@ describe("crossDomainClient", () => {
184
192
 
185
193
  describe("onSuccess handler", () => {
186
194
  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
- }));
195
+ storage.set(
196
+ cookieName,
197
+ JSON.stringify({
198
+ "better-auth.session_token": { value: "stale", expires: null },
199
+ })
200
+ );
190
201
 
191
202
  const onSuccess = getOnSuccessHook();
192
203
  await onSuccess({
@@ -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,10 @@ 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(([, value]) => !value.expires || new Date(value.expires) >= new Date())
51
+ .map(([key, value]) => `${key}=${value.value}`)
52
+ .join("; ");
87
53
  }
88
54
 
89
55
  export const crossDomainClient = (
@@ -104,6 +70,7 @@ export const crossDomainClient = (
104
70
 
105
71
  return {
106
72
  id: "cross-domain",
73
+ version: VERSION,
107
74
  $InferServerPlugin: {} as ReturnType<typeof crossDomain>,
108
75
  getActions(_, $store) {
109
76
  store = $store;
@@ -154,7 +121,13 @@ export const crossDomainClient = (
154
121
  if (!sessionData) return null;
155
122
  try {
156
123
  const parsed = JSON.parse(sessionData);
157
- if (parsed && typeof parsed === "object" && Object.keys(parsed).length === 0) return null;
124
+ if (
125
+ parsed &&
126
+ typeof parsed === "object" &&
127
+ Object.keys(parsed).length === 0
128
+ ) {
129
+ return null;
130
+ }
158
131
  return parsed;
159
132
  } catch {
160
133
  return null;
@@ -164,8 +137,8 @@ export const crossDomainClient = (
164
137
  },
165
138
  fetchPlugins: [
166
139
  {
167
- id: "convex",
168
- name: "Convex",
140
+ id: "cross-domain",
141
+ name: "Cross Domain",
169
142
  hooks: {
170
143
  async onSuccess(context) {
171
144
  if (!storage) {
@@ -239,7 +212,7 @@ export const crossDomainClient = (
239
212
  error: null,
240
213
  isPending: false,
241
214
  });
242
- storage.setItem(localCacheName, "{}");
215
+ await storage.setItem(localCacheName, "{}");
243
216
  }
244
217
  return {
245
218
  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
  });
@@ -65,6 +65,10 @@ const handler = (request: Request, opts: { convexSiteUrl: string }) => {
65
65
  const headers = new Headers(request.headers);
66
66
  headers.set("accept-encoding", "application/json");
67
67
  headers.set("host", new URL(opts.convexSiteUrl).host);
68
+ headers.set("x-forwarded-host", requestUrl.host);
69
+ headers.set("x-forwarded-proto", requestUrl.protocol.replace(/:$/, ""));
70
+ headers.set("x-better-auth-forwarded-host", requestUrl.host);
71
+ headers.set("x-better-auth-forwarded-proto", requestUrl.protocol.replace(/:$/, ""));
68
72
  return fetch(nextUrl, {
69
73
  method: request.method,
70
74
  headers,