better-auth 1.7.4 → 1.7.6

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 (46) hide show
  1. package/dist/api/index.d.mts +6 -6
  2. package/dist/api/routes/account.d.mts +1 -1
  3. package/dist/api/routes/callback.mjs +16 -64
  4. package/dist/api/routes/password.mjs +3 -5
  5. package/dist/api/routes/sign-in.d.mts +2 -2
  6. package/dist/api/routes/sign-in.mjs +2 -0
  7. package/dist/api/routes/sign-up.mjs +3 -10
  8. package/dist/api/routes/update-user.mjs +7 -20
  9. package/dist/client/config.d.mts +2 -2
  10. package/dist/client/query-atom.mjs +13 -0
  11. package/dist/client/query.mjs +37 -27
  12. package/dist/client/react/react-store.mjs +2 -1
  13. package/dist/client/session-atom.mjs +2 -1
  14. package/dist/context/create-context.mjs +3 -2
  15. package/dist/cookies/index.d.mts +3 -2
  16. package/dist/cookies/index.mjs +15 -36
  17. package/dist/db/get-migration.mjs +81 -60
  18. package/dist/oauth2/index.d.mts +2 -2
  19. package/dist/oauth2/index.mjs +2 -2
  20. package/dist/oauth2/link-account.d.mts +42 -14
  21. package/dist/oauth2/link-account.mjs +61 -1
  22. package/dist/package.mjs +1 -1
  23. package/dist/plugins/admin/admin.d.mts +3 -3
  24. package/dist/plugins/admin/admin.mjs +3 -1
  25. package/dist/plugins/admin/routes.mjs +4 -10
  26. package/dist/plugins/admin/types.d.mts +4 -1
  27. package/dist/plugins/captcha/constants.d.mts +1 -0
  28. package/dist/plugins/captcha/constants.mjs +2 -1
  29. package/dist/plugins/captcha/index.d.mts +2 -2
  30. package/dist/plugins/captcha/index.mjs +7 -0
  31. package/dist/plugins/captcha/types.d.mts +44 -2
  32. package/dist/plugins/captcha/utils.mjs +11 -1
  33. package/dist/plugins/captcha/verify-handlers/cloudflare-turnstile.mjs +27 -8
  34. package/dist/plugins/captcha/verify-handlers/vercel-botid.mjs +27 -0
  35. package/dist/plugins/email-otp/routes.mjs +3 -4
  36. package/dist/plugins/index.d.mts +2 -2
  37. package/dist/plugins/oauth-proxy/index.mjs +19 -5
  38. package/dist/plugins/organization/routes/crud-members.d.mts +3 -3
  39. package/dist/plugins/organization/schema.d.mts +2 -2
  40. package/dist/plugins/phone-number/routes.mjs +4 -4
  41. package/dist/plugins/username/index.d.mts +4 -4
  42. package/dist/plugins/username/index.mjs +2 -0
  43. package/dist/plugins/username/schema.d.mts +4 -4
  44. package/dist/test-utils/http-test-instance.d.mts +2 -2
  45. package/dist/utils/password.mjs +16 -2
  46. package/package.json +10 -13
@@ -268,6 +268,10 @@ const getSessionCookie = (request, config) => {
268
268
  if (sessionToken) return sessionToken;
269
269
  return null;
270
270
  };
271
+ async function matchesVersion(payload, version) {
272
+ const expectedVersion = typeof version === "string" ? version : await version(payload.session, payload.user);
273
+ return (payload.version || "1") === expectedVersion;
274
+ }
271
275
  const getCookieCache = async (request, config) => {
272
276
  const cookies = (request instanceof Headers || !("headers" in request) ? request : request.headers).get("cookie");
273
277
  if (!cookies) return null;
@@ -297,21 +301,12 @@ const getCookieCache = async (request, config) => {
297
301
  const secret = config?.secret || env.BETTER_AUTH_SECRET;
298
302
  if (!secret) throw new BetterAuthError("getCookieCache requires a secret to be provided. Either pass it as an option or set the BETTER_AUTH_SECRET environment variable");
299
303
  const payload = parseCookieCachePayload(await symmetricDecodeJWT(sessionData, secret, "better-auth-session"));
300
- if (payload) {
301
- if (config?.version) {
302
- const cookieVersion = payload.version || "1";
303
- let expectedVersion = "1";
304
- if (typeof config.version === "string") expectedVersion = config.version;
305
- else if (typeof config.version === "function") {
306
- const result = config.version(payload.session, payload.user);
307
- expectedVersion = isPromise(result) ? await result : result;
308
- }
309
- if (cookieVersion !== expectedVersion) return null;
310
- }
311
- if (isEmbeddedSessionExpired(payload.session)) return null;
312
- return payload;
304
+ if (!payload) return null;
305
+ if (config?.version) {
306
+ if (!await matchesVersion(payload, config.version)) return null;
313
307
  }
314
- return null;
308
+ if (isEmbeddedSessionExpired(payload.session)) return null;
309
+ return payload;
315
310
  } else if (strategy === "jwt") {
316
311
  const jwks = config?.jwt?.jwks;
317
312
  let payload;
@@ -324,21 +319,12 @@ const getCookieCache = async (request, config) => {
324
319
  if (!secret) throw new BetterAuthError("getCookieCache requires a secret to be provided. Either pass it as an option or set the BETTER_AUTH_SECRET environment variable");
325
320
  payload = parseCookieCachePayload(await verifyJWT(sessionData, secret));
326
321
  }
327
- if (payload) {
328
- if (config?.version) {
329
- const cookieVersion = payload.version || "1";
330
- let expectedVersion = "1";
331
- if (typeof config.version === "string") expectedVersion = config.version;
332
- else if (typeof config.version === "function") {
333
- const result = config.version(payload.session, payload.user);
334
- expectedVersion = isPromise(result) ? await result : result;
335
- }
336
- if (cookieVersion !== expectedVersion) return null;
337
- }
338
- if (isEmbeddedSessionExpired(payload.session)) return null;
339
- return payload;
322
+ if (!payload) return null;
323
+ if (config?.version) {
324
+ if (!await matchesVersion(payload, config.version)) return null;
340
325
  }
341
- return null;
326
+ if (isEmbeddedSessionExpired(payload.session)) return null;
327
+ return payload;
342
328
  } else {
343
329
  const secret = config?.secret || env.BETTER_AUTH_SECRET;
344
330
  if (!secret) throw new BetterAuthError("getCookieCache requires a secret to be provided. Either pass it as an option or set the BETTER_AUTH_SECRET environment variable");
@@ -351,14 +337,7 @@ const getCookieCache = async (request, config) => {
351
337
  const payload = parseCookieCachePayload(sessionDataPayload.session);
352
338
  if (!payload) return null;
353
339
  if (config?.version) {
354
- const cookieVersion = payload.version || "1";
355
- let expectedVersion = "1";
356
- if (typeof config.version === "string") expectedVersion = config.version;
357
- else if (typeof config.version === "function") {
358
- const result = config.version(payload.session, payload.user);
359
- expectedVersion = isPromise(result) ? await result : result;
360
- }
361
- if (cookieVersion !== expectedVersion) return null;
340
+ if (!await matchesVersion(payload, config.version)) return null;
362
341
  }
363
342
  if (typeof sessionDataPayload.expiresAt === "number" && sessionDataPayload.expiresAt < Date.now()) return null;
364
343
  if (isEmbeddedSessionExpired(payload.session)) return null;
@@ -115,10 +115,12 @@ function toDatabaseIndexMap(indexes) {
115
115
  }];
116
116
  }));
117
117
  }
118
- async function getDatabaseIndexMap(db, dbType, schemaName, tableNames, introspectIndexes) {
118
+ async function getDatabaseIndexMap(db, target, tableNames, introspectIndexes) {
119
119
  if (introspectIndexes) return toDatabaseIndexMap(await introspectIndexes(tableNames));
120
120
  let rows;
121
- if (dbType === "sqlite") rows = (await sql`
121
+ switch (target.type) {
122
+ case "sqlite":
123
+ rows = (await sql`
122
124
  SELECT
123
125
  tables.name AS "tableName",
124
126
  index_list.name AS "indexName",
@@ -131,7 +133,9 @@ async function getDatabaseIndexMap(db, dbType, schemaName, tableNames, introspec
131
133
  INNER JOIN pragma_index_info(index_list.name) AS index_info
132
134
  WHERE tables.type = 'table'
133
135
  `.execute(db)).rows;
134
- else if (dbType === "postgres") rows = (await sql`
136
+ break;
137
+ case "postgres":
138
+ rows = (await sql`
135
139
  SELECT
136
140
  table_class.relname AS "tableName",
137
141
  index_class.relname AS "indexName",
@@ -153,11 +157,13 @@ async function getDatabaseIndexMap(db, dbType, schemaName, tableNames, introspec
153
157
  LEFT JOIN pg_attribute AS index_attribute
154
158
  ON index_attribute.attrelid = table_class.oid
155
159
  AND index_attribute.attnum = index_column.attribute_number
156
- WHERE table_namespace.nspname = ${schemaName}
160
+ WHERE table_namespace.nspname = ${target.schema}
157
161
  AND table_class.relkind = 'r'
158
162
  AND index_column.ordinality <= index_data.indnkeyatts
159
163
  `.execute(db)).rows;
160
- else if (dbType === "mysql") rows = (await sql`
164
+ break;
165
+ case "mysql":
166
+ rows = (await sql`
161
167
  SELECT
162
168
  table_name AS tableName,
163
169
  index_name AS indexName,
@@ -169,7 +175,9 @@ async function getDatabaseIndexMap(db, dbType, schemaName, tableNames, introspec
169
175
  FROM information_schema.statistics
170
176
  WHERE table_schema = DATABASE()
171
177
  `.execute(db)).rows;
172
- else rows = (await sql`
178
+ break;
179
+ case "mssql":
180
+ rows = (await sql`
173
181
  SELECT
174
182
  tables.name AS "tableName",
175
183
  indexes.name AS "indexName",
@@ -190,10 +198,12 @@ async function getDatabaseIndexMap(db, dbType, schemaName, tableNames, introspec
190
198
  INNER JOIN sys.columns AS columns
191
199
  ON columns.object_id = index_columns.object_id
192
200
  AND columns.column_id = index_columns.column_id
193
- WHERE table_schemas.name = ${schemaName}
201
+ WHERE table_schemas.name = ${target.schema}
194
202
  AND indexes.name IS NOT NULL
195
203
  AND index_columns.key_ordinal > 0
196
204
  `.execute(db)).rows;
205
+ break;
206
+ }
197
207
  const indexMetadata = /* @__PURE__ */ new Map();
198
208
  for (const row of rows) {
199
209
  const table = row.tableName ?? row.table_name ?? row.TABLE_NAME ?? row.tablename ?? row.tbl_name;
@@ -228,46 +238,45 @@ async function getDatabaseIndexMap(db, dbType, schemaName, tableNames, introspec
228
238
  }
229
239
  return toDatabaseIndexMap([...indexMetadata.values()]);
230
240
  }
231
- async function getDatabaseColumnBounds(db, dbType, schemaName) {
232
- if (dbType !== "mysql" && dbType !== "mssql") return /* @__PURE__ */ new Map();
241
+ async function getDatabaseColumnBounds(db, target) {
233
242
  let rows;
234
- if (dbType === "mysql") rows = (await sql`
243
+ switch (target.type) {
244
+ case "postgres":
245
+ case "sqlite": return /* @__PURE__ */ new Map();
246
+ case "mysql":
247
+ rows = (await sql`
235
248
  SELECT
236
249
  table_name AS tableName,
237
250
  column_name AS columnName,
238
- data_type AS dataType,
239
- character_maximum_length AS characterMaximumLength
251
+ character_octet_length AS maxIndexBytes
240
252
  FROM information_schema.columns
241
253
  WHERE table_schema = DATABASE()
242
254
  `.execute(db)).rows;
243
- else rows = (await sql`
255
+ break;
256
+ case "mssql":
257
+ rows = (await sql`
244
258
  SELECT
245
259
  tables.name AS "tableName",
246
260
  columns.name AS "columnName",
247
- types.name AS "dataType",
248
- columns.max_length AS "maxLength"
261
+ columns.max_length AS "maxIndexBytes"
249
262
  FROM sys.columns AS columns
250
263
  INNER JOIN sys.tables AS tables
251
264
  ON tables.object_id = columns.object_id
252
265
  INNER JOIN sys.schemas AS table_schemas
253
266
  ON table_schemas.schema_id = tables.schema_id
254
- INNER JOIN sys.types AS types
255
- ON types.user_type_id = columns.user_type_id
256
- WHERE table_schemas.name = ${schemaName}
267
+ WHERE table_schemas.name = ${target.schema}
257
268
  `.execute(db)).rows;
258
- return new Map(rows.flatMap((row) => {
269
+ break;
270
+ }
271
+ const bounds = /* @__PURE__ */ new Map();
272
+ for (const row of rows) {
259
273
  const table = row.tableName ?? row.TABLE_NAME;
260
274
  const column = row.columnName ?? row.COLUMN_NAME;
261
- const dataType = (row.dataType ?? row.DATA_TYPE)?.toLowerCase();
262
- if (!table || !column || !dataType) return [];
263
- if (dbType === "mysql") {
264
- const characterLength = row.characterMaximumLength ?? row.CHARACTER_MAXIMUM_LENGTH;
265
- const maxIndexBytes = characterLength === null || characterLength === void 0 ? null : Number(characterLength) * 4;
266
- return [[createDatabaseColumnKey(table, column), { maxIndexBytes }]];
267
- }
268
- const maxLength = Number(row.maxLength ?? -1);
269
- return [[createDatabaseColumnKey(table, column), { maxIndexBytes: maxLength < 0 ? null : maxLength }]];
270
- }));
275
+ if (!table || !column) continue;
276
+ const maxIndexBytes = Number(row.maxIndexBytes ?? row.MAX_INDEX_BYTES ?? -1);
277
+ bounds.set(createDatabaseColumnKey(table, column), { maxIndexBytes: maxIndexBytes < 0 ? null : maxIndexBytes });
278
+ }
279
+ return bounds;
271
280
  }
272
281
  function assertExistingTableIndexFits({ columnBounds, dbType, existingColumns, fields, indexes, index, table }) {
273
282
  const byteBudget = dbType === "mysql" ? 3072 : 1700;
@@ -341,7 +350,7 @@ async function getMigrations(config, { throwOnUnsafe = true } = {}) {
341
350
  if (throwOnUnsafe) throw new UnsafeMigrationError(message);
342
351
  unsafeChanges.push(message);
343
352
  };
344
- let { kysely: db, databaseType: dbType, introspectIndexes } = await createKyselyAdapter(config);
353
+ let { kysely: db, databaseType: dbType, introspectIndexes, schemaName } = await createKyselyAdapter(config);
345
354
  if (!dbType) {
346
355
  logger.warn("Could not determine database type, defaulting to sqlite. Please provide a type in the database options to avoid this.");
347
356
  dbType = "sqlite";
@@ -350,39 +359,50 @@ async function getMigrations(config, { throwOnUnsafe = true } = {}) {
350
359
  logger.error("Only kysely adapter is supported for migrations. You can use `generate` command to generate the schema, if you're using a different adapter.");
351
360
  process.exit(1);
352
361
  }
353
- let currentSchema = dbType === "mssql" ? await getMssqlSchema(db) : "public";
354
- if (dbType === "postgres") {
355
- currentSchema = await getPostgresSchema(db);
356
- logger.debug(`PostgreSQL migration: Using schema '${currentSchema}' (from search_path)`);
357
- try {
358
- const schemaCheck = await sql`
359
- SELECT schema_name
360
- FROM information_schema.schemata
361
- WHERE schema_name = ${currentSchema}
362
- `.execute(db);
363
- if (!(schemaCheck.rows[0]?.schema_name ?? schemaCheck.rows[0]?.schemaName)) logger.warn(`Schema '${currentSchema}' does not exist. Tables will be inspected from available schemas. Consider creating the schema first or checking your database configuration.`);
364
- } catch (error) {
365
- logger.debug(`Could not verify schema existence: ${error instanceof Error ? error.message : String(error)}`);
366
- }
367
- } else if (dbType === "mssql") logger.debug(`SQL Server migration: Using schema '${currentSchema}' (from the current user's default schema)`);
368
362
  const allTableMetadata = await db.introspection.getTables();
369
- const databaseIndexMap = await getDatabaseIndexMap(db, dbType, currentSchema, allTableMetadata.map((table) => table.name), introspectIndexes);
370
- const databaseColumnBounds = await getDatabaseColumnBounds(db, dbType, currentSchema);
363
+ let target;
371
364
  let tableMetadata = allTableMetadata;
372
- if (dbType === "postgres") try {
373
- const tablesInSchema = await sql`
374
- SELECT table_name
375
- FROM information_schema.tables
376
- WHERE table_schema = ${currentSchema}
377
- AND table_type = 'BASE TABLE'
378
- `.execute(db);
379
- const tableNamesInSchema = new Set(tablesInSchema.rows.map((row) => row.table_name ?? row.tableName));
380
- tableMetadata = allTableMetadata.filter((table) => table.schema === currentSchema && tableNamesInSchema.has(table.name));
381
- logger.debug(`Found ${tableMetadata.length} table(s) in schema '${currentSchema}': ${tableMetadata.map((t) => t.name).join(", ") || "(none)"}`);
382
- } catch (error) {
383
- logger.warn(`Could not filter tables by schema. Using all discovered tables. Error: ${error instanceof Error ? error.message : String(error)}`);
365
+ switch (dbType) {
366
+ case "postgres": {
367
+ const schema = schemaName ?? await getPostgresSchema(db);
368
+ target = {
369
+ type: "postgres",
370
+ schema
371
+ };
372
+ logger.debug(`PostgreSQL migration: Using schema '${schema}' (${schemaName ? "from database.schemaName" : "from search_path"})`);
373
+ try {
374
+ if (!(await db.introspection.getSchemas()).some(({ name }) => name === schema)) if (schemaName) logger.debug(`Schema '${schema}' does not exist yet. The migration creates it before creating tables.`);
375
+ else logger.warn(`Schema '${schema}' does not exist. Create it before running migrations or check your database configuration.`);
376
+ } catch (error) {
377
+ logger.debug(`Could not verify schema existence: ${error instanceof Error ? error.message : String(error)}`);
378
+ }
379
+ /**
380
+ * Kysely 0.28 does not expose `isForeign`, while 0.29 adds foreign table metadata.
381
+ * @see https://github.com/kysely-org/kysely/pull/1494
382
+ */
383
+ tableMetadata = allTableMetadata.filter((table) => table.schema === schema && !table.isView && !("isForeign" in table && table.isForeign));
384
+ logger.debug(`Found ${tableMetadata.length} table(s) in schema '${schema}': ${tableMetadata.map((table) => table.name).join(", ") || "(none)"}`);
385
+ break;
386
+ }
387
+ case "mssql": {
388
+ const schema = await getMssqlSchema(db);
389
+ target = {
390
+ type: "mssql",
391
+ schema
392
+ };
393
+ logger.debug(`SQL Server migration: Using schema '${schema}' (from the current user's default schema)`);
394
+ tableMetadata = allTableMetadata.filter((table) => table.schema === schema);
395
+ break;
396
+ }
397
+ case "mysql":
398
+ target = { type: "mysql" };
399
+ break;
400
+ case "sqlite":
401
+ target = { type: "sqlite" };
402
+ break;
384
403
  }
385
- else if (dbType === "mssql") tableMetadata = allTableMetadata.filter((table) => table.schema === currentSchema);
404
+ const databaseIndexMap = await getDatabaseIndexMap(db, target, allTableMetadata.map((table) => table.name), introspectIndexes);
405
+ const databaseColumnBounds = await getDatabaseColumnBounds(db, target);
386
406
  const schemaProblems = diffSchema(toPhysicalSchema(db, betterAuthSchema), toIntrospectedTables(tableMetadata)).filter((finding) => finding.kind === "unexpected-required-column").map((finding) => formatSchemaFinding(finding, "database"));
387
407
  const toBeCreated = [];
388
408
  const toBeAdded = [];
@@ -464,6 +484,7 @@ async function getMigrations(config, { throwOnUnsafe = true } = {}) {
464
484
  });
465
485
  }
466
486
  const migrations = [];
487
+ if (schemaName && toBeCreated.length > 0) migrations.push(db.schema.createSchema(schemaName).ifNotExists());
467
488
  const useUUIDs = config.advanced?.database?.generateId === "uuid";
468
489
  const useNumberId = config.advanced?.database?.generateId === "serial";
469
490
  function getType(field, fieldName, tableIndexStringLength) {
@@ -1,5 +1,5 @@
1
1
  import { GenerateStateOptions, generateIdTokenNonce, generateState, parseState } from "./state.mjs";
2
- import { applyUpdateUserInfoOnLink, handleOAuthUserInfo } from "./link-account.mjs";
2
+ import { applyUpdateUserInfoOnLink, handleOAuthUserInfo, linkOAuthAccount } from "./link-account.mjs";
3
3
  import { decryptOAuthToken, getOAuthCallbackPath, setTokenUtil } from "./utils.mjs";
4
4
  export * from "@better-auth/core/oauth2";
5
- export { GenerateStateOptions, applyUpdateUserInfoOnLink, decryptOAuthToken, generateIdTokenNonce, generateState, getOAuthCallbackPath, handleOAuthUserInfo, parseState, setTokenUtil };
5
+ export { GenerateStateOptions, applyUpdateUserInfoOnLink, decryptOAuthToken, generateIdTokenNonce, generateState, getOAuthCallbackPath, handleOAuthUserInfo, linkOAuthAccount, parseState, setTokenUtil };
@@ -1,5 +1,5 @@
1
1
  import { decryptOAuthToken, getOAuthCallbackPath, setTokenUtil } from "./utils.mjs";
2
- import { applyUpdateUserInfoOnLink, handleOAuthUserInfo } from "./link-account.mjs";
2
+ import { applyUpdateUserInfoOnLink, handleOAuthUserInfo, linkOAuthAccount } from "./link-account.mjs";
3
3
  import { generateIdTokenNonce, generateState, parseState } from "./state.mjs";
4
4
  export * from "@better-auth/core/oauth2";
5
- export { applyUpdateUserInfoOnLink, decryptOAuthToken, generateIdTokenNonce, generateState, getOAuthCallbackPath, handleOAuthUserInfo, parseState, setTokenUtil };
5
+ export { applyUpdateUserInfoOnLink, decryptOAuthToken, generateIdTokenNonce, generateState, getOAuthCallbackPath, handleOAuthUserInfo, linkOAuthAccount, parseState, setTokenUtil };
@@ -2,9 +2,49 @@ import { Account, User } from "../types/models.mjs";
2
2
  import { GenericEndpointContext, UserProvisioningSource } from "@better-auth/core";
3
3
 
4
4
  //#region src/oauth2/link-account.d.ts
5
+ type OAuthAccountData = Omit<Account, "id" | "userId" | "createdAt" | "updatedAt">;
6
+ /**
7
+ * Provider profile a freshly linked account may copy onto the local user.
8
+ * `email` and `emailVerified` are identity anchors and are stripped before
9
+ * the remaining fields are written. Provider identity is resolved separately
10
+ * from the raw profile through the provider's account-key contract.
11
+ */
12
+ type LinkedProviderProfile = {
13
+ name?: string | undefined;
14
+ email?: string | null | undefined;
15
+ emailVerified?: boolean | undefined;
16
+ image?: string | null | undefined;
17
+ };
18
+ interface LinkOAuthAccountOptions {
19
+ link: {
20
+ userId: string;
21
+ email: string;
22
+ };
23
+ userInfo: LinkedProviderProfile;
24
+ account: OAuthAccountData;
25
+ profile: Record<string, unknown>;
26
+ scopes?: string[] | undefined;
27
+ }
28
+ interface LinkOAuthAccountFailure {
29
+ linked: false;
30
+ error: {
31
+ code: string;
32
+ message?: string | undefined;
33
+ };
34
+ }
35
+ type LinkOAuthAccountResult = {
36
+ linked: true;
37
+ } | LinkOAuthAccountFailure;
38
+ declare function linkOAuthAccount(c: GenericEndpointContext, {
39
+ link,
40
+ userInfo,
41
+ account,
42
+ profile,
43
+ scopes
44
+ }: LinkOAuthAccountOptions): Promise<LinkOAuthAccountResult>;
5
45
  declare function handleOAuthUserInfo(c: GenericEndpointContext, opts: {
6
46
  userInfo: Omit<User, "createdAt" | "updatedAt">;
7
- account: Omit<Account, "id" | "userId" | "createdAt" | "updatedAt">;
47
+ account: OAuthAccountData;
8
48
  callbackURL?: string | undefined;
9
49
  disableSignUp?: boolean | undefined;
10
50
  overrideUserInfo?: boolean | undefined;
@@ -67,18 +107,6 @@ declare function handleOAuthUserInfo(c: GenericEndpointContext, opts: {
67
107
  password?: string | null | undefined;
68
108
  } | null;
69
109
  }>;
70
- /**
71
- * Provider profile a freshly linked account may copy onto the local user.
72
- * `email` and `emailVerified` are identity anchors and are stripped before
73
- * the remaining fields are written. Provider identity is resolved separately
74
- * from the raw profile through the provider's account-key contract.
75
- */
76
- type LinkedProviderProfile = {
77
- name?: string | undefined;
78
- email?: string | null | undefined;
79
- emailVerified?: boolean | undefined;
80
- image?: string | null | undefined;
81
- };
82
110
  /**
83
111
  * Apply the `account.accountLinking.updateUserInfoOnLink` policy: when enabled,
84
112
  * copy the freshly linked provider's profile onto the local user, matching the
@@ -93,4 +121,4 @@ type LinkedProviderProfile = {
93
121
  */
94
122
  declare function applyUpdateUserInfoOnLink(c: GenericEndpointContext, userId: string, userInfo: LinkedProviderProfile): Promise<User | undefined>;
95
123
  //#endregion
96
- export { applyUpdateUserInfoOnLink, handleOAuthUserInfo };
124
+ export { applyUpdateUserInfoOnLink, handleOAuthUserInfo, linkOAuthAccount };
@@ -8,7 +8,67 @@ import { createEmailVerificationToken } from "../api/routes/email-verification.m
8
8
  import { queueAfterTransactionHook, runWithTransaction } from "@better-auth/core/context";
9
9
  import { isDevelopment } from "@better-auth/core/env";
10
10
  import { APIError } from "@better-auth/core/error";
11
+ import { mergeScopes } from "@better-auth/core/oauth2";
11
12
  //#region src/oauth2/link-account.ts
13
+ function linkOAuthAccountFailure(code, message) {
14
+ return {
15
+ linked: false,
16
+ error: {
17
+ code,
18
+ message
19
+ }
20
+ };
21
+ }
22
+ async function linkOAuthAccount(c, { link, userInfo, account, profile, scopes }) {
23
+ try {
24
+ await assertValidUserInfo(c, {
25
+ user: {
26
+ ...userInfo,
27
+ id: link.userId,
28
+ email: userInfo.email ?? void 0
29
+ },
30
+ source: {
31
+ action: "link-account",
32
+ method: "oauth",
33
+ oauth: {
34
+ providerId: account.providerId,
35
+ profile
36
+ }
37
+ }
38
+ });
39
+ } catch (error) {
40
+ if (!isAPIError(error) || !error.body?.code) throw error;
41
+ return linkOAuthAccountFailure(error.body.code, error.body.message);
42
+ }
43
+ if (!c.context.trustedProviders.includes(account.providerId) && !userInfo.emailVerified || c.context.options.account?.accountLinking?.enabled === false) {
44
+ c.context.logger.error("Unable to link account - untrusted provider");
45
+ return linkOAuthAccountFailure(OAUTH_CALLBACK_ERROR_CODES.UNABLE_TO_LINK_ACCOUNT);
46
+ }
47
+ if (userInfo.email?.toLowerCase() !== link.email.toLowerCase() && c.context.options.account?.accountLinking?.allowDifferentEmails !== true) return linkOAuthAccountFailure(OAUTH_CALLBACK_ERROR_CODES.EMAIL_DOES_NOT_MATCH);
48
+ const existingAccount = await c.context.internalAdapter.findAccountByKey(account);
49
+ if (existingAccount) {
50
+ if (existingAccount.userId.toString() !== link.userId.toString()) return linkOAuthAccountFailure(OAUTH_CALLBACK_ERROR_CODES.ACCOUNT_ALREADY_LINKED_TO_DIFFERENT_USER);
51
+ const mergedScope = mergeScopes(existingAccount.scope, scopes);
52
+ const updateData = Object.fromEntries(Object.entries({
53
+ providerId: account.providerId,
54
+ accessToken: await setTokenUtil(account.accessToken, c.context),
55
+ refreshToken: await setTokenUtil(account.refreshToken, c.context),
56
+ idToken: account.idToken,
57
+ accessTokenExpiresAt: account.accessTokenExpiresAt,
58
+ refreshTokenExpiresAt: account.refreshTokenExpiresAt,
59
+ scope: mergedScope || void 0
60
+ }).filter(([_, value]) => value !== void 0));
61
+ await c.context.internalAdapter.updateAccount(existingAccount.id, updateData);
62
+ } else if (!await c.context.internalAdapter.createAccount({
63
+ userId: link.userId,
64
+ ...account,
65
+ accessToken: await setTokenUtil(account.accessToken, c.context),
66
+ refreshToken: await setTokenUtil(account.refreshToken, c.context),
67
+ scope: scopes?.join(",") ?? account.scope
68
+ })) return linkOAuthAccountFailure(OAUTH_CALLBACK_ERROR_CODES.UNABLE_TO_LINK_ACCOUNT);
69
+ await applyUpdateUserInfoOnLink(c, link.userId, userInfo);
70
+ return { linked: true };
71
+ }
12
72
  async function handleOAuthUserInfo(c, opts) {
13
73
  const { userInfo, account, callbackURL, disableSignUp, overrideUserInfo } = opts;
14
74
  const source = opts.source ?? {
@@ -326,4 +386,4 @@ async function applyUpdateUserInfoOnLink(c, userId, userInfo) {
326
386
  }
327
387
  }
328
388
  //#endregion
329
- export { applyUpdateUserInfoOnLink, handleOAuthUserInfo };
389
+ export { applyUpdateUserInfoOnLink, handleOAuthUserInfo, linkOAuthAccount };
package/dist/package.mjs CHANGED
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "1.7.4";
2
+ var version = "1.7.6";
3
3
  //#endregion
4
4
  export { version };
@@ -297,13 +297,13 @@ declare const admin: <O extends AdminOptions>(options?: O | undefined) => {
297
297
  filterField: _$zod.ZodOptional<_$zod.ZodString>;
298
298
  filterValue: _$zod.ZodOptional<_$zod.ZodUnion<[_$zod.ZodUnion<[_$zod.ZodUnion<[_$zod.ZodUnion<[_$zod.ZodString, _$zod.ZodNumber]>, _$zod.ZodBoolean]>, _$zod.ZodArray<_$zod.ZodString>]>, _$zod.ZodArray<_$zod.ZodNumber>]>>;
299
299
  filterOperator: _$zod.ZodOptional<_$zod.ZodEnum<{
300
+ in: "in";
301
+ lte: "lte";
300
302
  eq: "eq";
301
303
  ne: "ne";
304
+ lt: "lt";
302
305
  gt: "gt";
303
306
  gte: "gte";
304
- lt: "lt";
305
- lte: "lte";
306
- in: "in";
307
307
  not_in: "not_in";
308
308
  contains: "contains";
309
309
  starts_with: "starts_with";
@@ -42,8 +42,10 @@ const admin = (options) => {
42
42
  });
43
43
  return;
44
44
  }
45
+ const bannedUserMessage = opts.bannedUserMessage;
46
+ const message = typeof bannedUserMessage === "function" ? await bannedUserMessage(user) : bannedUserMessage;
45
47
  throw APIError.from("FORBIDDEN", {
46
- message: opts.bannedUserMessage,
48
+ message,
47
49
  code: "BANNED_USER"
48
50
  });
49
51
  }
@@ -2,6 +2,7 @@ import { parseSessionOutput, parseUserOutput } from "../../db/schema.mjs";
2
2
  import { getDate } from "../../utils/date.mjs";
3
3
  import { deleteSessionCookie, expireCookie, setSessionCookie } from "../../cookies/index.mjs";
4
4
  import { getAuthoritativeSessionFromCtx, getSessionFromCtx } from "../../api/routes/session.mjs";
5
+ import { assertPasswordNotTooLong, assertPasswordNotTooShort } from "../../utils/password.mjs";
5
6
  import { ADMIN_ERROR_CODES } from "./error-codes.mjs";
6
7
  import { hasPermission } from "./has-permission.mjs";
7
8
  import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
@@ -190,6 +191,7 @@ const createUser = (opts) => createAuthEndpoint("/admin/create-user", {
190
191
  }
191
192
  const email = ctx.body.email.toLowerCase();
192
193
  if (!z.email().safeParse(email).success) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.INVALID_EMAIL);
194
+ if (ctx.body.password) assertPasswordNotTooLong(ctx, ctx.body.password);
193
195
  if (await ctx.context.internalAdapter.findUserByEmail(email)) throw APIError.from("BAD_REQUEST", ADMIN_ERROR_CODES.USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL);
194
196
  const user = await ctx.context.internalAdapter.createUser({
195
197
  ...userData,
@@ -823,16 +825,8 @@ const setUserPassword = (opts) => createAuthEndpoint("/admin/set-user-password",
823
825
  permissions: { user: ["set-password"] }
824
826
  })) throw APIError.from("FORBIDDEN", ADMIN_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_SET_USERS_PASSWORD);
825
827
  const { newPassword, userId } = ctx.body;
826
- const minPasswordLength = ctx.context.password.config.minPasswordLength;
827
- if (newPassword.length < minPasswordLength) {
828
- ctx.context.logger.warn("Password is too short");
829
- throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.PASSWORD_TOO_SHORT);
830
- }
831
- const maxPasswordLength = ctx.context.password.config.maxPasswordLength;
832
- if (newPassword.length > maxPasswordLength) {
833
- ctx.context.logger.warn("Password is too long");
834
- throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.PASSWORD_TOO_LONG);
835
- }
828
+ assertPasswordNotTooShort(ctx, newPassword);
829
+ assertPasswordNotTooLong(ctx, newPassword);
836
830
  const user = await ctx.context.internalAdapter.findUserById(userId);
837
831
  if (!user) throw APIError.from("NOT_FOUND", BASE_ERROR_CODES.USER_NOT_FOUND);
838
832
  const hashedPassword = await ctx.context.password.hash(newPassword);
@@ -2,6 +2,7 @@ import { Session, User } from "../../types/models.mjs";
2
2
  import { InferOptionSchema } from "../../types/plugins.mjs";
3
3
  import { AccessControl, Role } from "../access/types.mjs";
4
4
  import { AdminSchema } from "./schema.mjs";
5
+ import { Awaitable } from "@better-auth/core";
5
6
 
6
7
  //#region src/plugins/admin/types.d.ts
7
8
  interface UserWithRole extends User {
@@ -70,8 +71,10 @@ interface AdminOptions {
70
71
  * Message to show when a user is banned
71
72
  *
72
73
  * By default, the message is "You have been banned from this application"
74
+ *
75
+ * Can also be a function that receives the banned user and returns a message.
73
76
  */
74
- bannedUserMessage?: string | undefined;
77
+ bannedUserMessage?: string | ((user: UserWithRole & Record<string, unknown>) => Awaitable<string>) | undefined;
75
78
  /**
76
79
  * Whether to allow impersonating other admins.
77
80
  *
@@ -4,6 +4,7 @@ declare const Providers: {
4
4
  readonly GOOGLE_RECAPTCHA: "google-recaptcha";
5
5
  readonly HCAPTCHA: "hcaptcha";
6
6
  readonly CAPTCHAFOX: "captchafox";
7
+ readonly VERCEL_BOTID: "vercel-botid";
7
8
  };
8
9
  //#endregion
9
10
  export { Providers };
@@ -15,7 +15,8 @@ const Providers = {
15
15
  CLOUDFLARE_TURNSTILE: "cloudflare-turnstile",
16
16
  GOOGLE_RECAPTCHA: "google-recaptcha",
17
17
  HCAPTCHA: "hcaptcha",
18
- CAPTCHAFOX: "captchafox"
18
+ CAPTCHAFOX: "captchafox",
19
+ VERCEL_BOTID: "vercel-botid"
19
20
  };
20
21
  const siteVerifyMap = {
21
22
  [Providers.CLOUDFLARE_TURNSTILE]: "https://challenges.cloudflare.com/turnstile/v0/siteverify",
@@ -1,4 +1,4 @@
1
- import { BaseCaptchaOptions, CaptchaFoxOptions, CaptchaOptions, CloudflareTurnstileOptions, GoogleRecaptchaOptions, HCaptchaOptions, Provider } from "./types.mjs";
1
+ import { BaseCaptchaOptions, BotIdVerification, CaptchaFoxOptions, CaptchaOptions, CloudflareTurnstileOptions, GoogleRecaptchaOptions, HCaptchaOptions, Provider, ValidateRequestContext, VercelBotIdOptions } from "./types.mjs";
2
2
  import * as _$_better_auth_core0 from "@better-auth/core";
3
3
  import * as _$_better_auth_core_utils_error_codes0 from "@better-auth/core/utils/error-codes";
4
4
 
@@ -24,4 +24,4 @@ declare const captcha: (options: CaptchaOptions) => {
24
24
  options: CaptchaOptions;
25
25
  };
26
26
  //#endregion
27
- export { BaseCaptchaOptions, CaptchaFoxOptions, CaptchaOptions, CloudflareTurnstileOptions, GoogleRecaptchaOptions, HCaptchaOptions, Provider, captcha };
27
+ export { BaseCaptchaOptions, BotIdVerification, CaptchaFoxOptions, CaptchaOptions, CloudflareTurnstileOptions, GoogleRecaptchaOptions, HCaptchaOptions, Provider, ValidateRequestContext, VercelBotIdOptions, captcha };
@@ -7,6 +7,7 @@ import { captchaFox } from "./verify-handlers/captchafox.mjs";
7
7
  import { cloudflareTurnstile } from "./verify-handlers/cloudflare-turnstile.mjs";
8
8
  import { googleRecaptcha } from "./verify-handlers/google-recaptcha.mjs";
9
9
  import { hCaptcha } from "./verify-handlers/h-captcha.mjs";
10
+ import { vercelBotId } from "./verify-handlers/vercel-botid.mjs";
10
11
  import { getIP } from "@better-auth/core/utils/ip";
11
12
  //#region src/plugins/captcha/index.ts
12
13
  const normalizeEndpointPath = (pathname, basePath) => {
@@ -26,6 +27,11 @@ const captcha = (options) => ({
26
27
  const basePath = ctx.options.basePath ?? "/api/auth";
27
28
  const pathname = normalizeEndpointPath(url.pathname, basePath);
28
29
  if (!endpoints.some((endpoint) => endpoint.includes("*") ? wildcardMatch(endpoint)(pathname) : endpoint === pathname)) return;
30
+ if (options.provider === Providers.VERCEL_BOTID) return await vercelBotId({
31
+ request,
32
+ checkBotId: options.checkBotId,
33
+ validateRequest: options.validateRequest
34
+ });
29
35
  if (!options.secretKey) throw new Error(INTERNAL_ERROR_CODES.MISSING_SECRET_KEY.message);
30
36
  const captchaResponse = request.headers.get("x-captcha-response");
31
37
  const remoteUserIP = getIP(request, ctx.options) ?? void 0;
@@ -42,6 +48,7 @@ const captcha = (options) => ({
42
48
  };
43
49
  if (options.provider === Providers.CLOUDFLARE_TURNSTILE) return await cloudflareTurnstile({
44
50
  ...handlerParams,
51
+ logger: ctx.logger,
45
52
  expectedAction: options.expectedAction,
46
53
  allowedHostnames: options.allowedHostnames
47
54
  });