@better-auth/core 1.6.10 → 1.6.11

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.
@@ -2,7 +2,7 @@
2
2
  const symbol = Symbol.for("better-auth:global");
3
3
  let bind = null;
4
4
  const __context = {};
5
- const __betterAuthVersion = "1.6.10";
5
+ const __betterAuthVersion = "1.6.11";
6
6
  /**
7
7
  * We store context instance in the globalThis.
8
8
  *
@@ -57,6 +57,7 @@ const createAdapterFactory = ({ adapter: customAdapter, config: cfg }) => (optio
57
57
  else if (method === "findMany" && !config.debugLogs.findMany) return;
58
58
  else if (method === "delete" && !config.debugLogs.delete) return;
59
59
  else if (method === "deleteMany" && !config.debugLogs.deleteMany) return;
60
+ else if (method === "consumeOne" && !config.debugLogs.consumeOne) return;
60
61
  else if (method === "count" && !config.debugLogs.count) return;
61
62
  }
62
63
  logger.info(`[${config.adapterName}]`, ...args);
@@ -676,6 +677,65 @@ const createAdapterFactory = ({ adapter: customAdapter, config: cfg }) => (optio
676
677
  });
677
678
  return res;
678
679
  },
680
+ consumeOne: async ({ model: unsafeModel, where: unsafeWhere }) => {
681
+ transactionId++;
682
+ const thisTransactionId = transactionId;
683
+ const model = getModelName(unsafeModel);
684
+ const where = transformWhereClause({
685
+ model: unsafeModel,
686
+ where: unsafeWhere,
687
+ action: "consumeOne"
688
+ });
689
+ unsafeModel = getDefaultModelName(unsafeModel);
690
+ debugLog({ method: "consumeOne" }, `${formatTransactionId(thisTransactionId)} ${formatStep(1, 3)}`, `${formatMethod("consumeOne")} ${formatAction("ConsumeOne")}:`, {
691
+ model,
692
+ where
693
+ });
694
+ let res;
695
+ let resultNeedsOutputTransform = true;
696
+ if (adapterInstance.consumeOne) res = await withSpan(`db consumeOne ${model}`, {
697
+ [ATTR_DB_OPERATION_NAME]: "consumeOne",
698
+ [ATTR_DB_COLLECTION_NAME]: model
699
+ }, () => adapterInstance.consumeOne({
700
+ model,
701
+ where
702
+ }));
703
+ else {
704
+ res = await withSpan(`db consumeOne ${model}`, {
705
+ [ATTR_DB_OPERATION_NAME]: "consumeOne",
706
+ [ATTR_DB_COLLECTION_NAME]: model
707
+ }, () => adapter.transaction(async (trx) => {
708
+ const target = (await trx.findMany({
709
+ model: unsafeModel,
710
+ where: unsafeWhere,
711
+ limit: 1
712
+ }))[0];
713
+ if (!target) return null;
714
+ return await trx.deleteMany({
715
+ model: unsafeModel,
716
+ where: [...unsafeWhere, {
717
+ field: "id",
718
+ value: target.id,
719
+ operator: "eq",
720
+ connector: "AND",
721
+ mode: "sensitive"
722
+ }]
723
+ }) > 0 ? target : null;
724
+ }));
725
+ resultNeedsOutputTransform = false;
726
+ }
727
+ debugLog({ method: "consumeOne" }, `${formatTransactionId(thisTransactionId)} ${formatStep(2, 3)}`, `${formatMethod("consumeOne")} ${formatAction("DB Result")}:`, {
728
+ model,
729
+ data: res
730
+ });
731
+ let transformed = res;
732
+ if (!config.disableTransformOutput && resultNeedsOutputTransform && res) transformed = await transformOutput(res, unsafeModel, void 0, void 0);
733
+ debugLog({ method: "consumeOne" }, `${formatTransactionId(thisTransactionId)} ${formatStep(3, 3)}`, `${formatMethod("consumeOne")} ${formatAction("Parsed Result")}:`, {
734
+ model,
735
+ data: transformed
736
+ });
737
+ return transformed;
738
+ },
679
739
  count: async ({ model: unsafeModel, where: unsafeWhere }) => {
680
740
  transactionId++;
681
741
  const thisTransactionId = transactionId;
@@ -22,6 +22,7 @@ type DBAdapterDebugLogOption = boolean | {
22
22
  findMany?: boolean | undefined;
23
23
  delete?: boolean | undefined;
24
24
  deleteMany?: boolean | undefined;
25
+ consumeOne?: boolean | undefined;
25
26
  count?: boolean | undefined;
26
27
  } | {
27
28
  /**
@@ -197,7 +198,7 @@ interface DBAdapterFactoryConfig<Options extends BetterAuthOptions = BetterAuthO
197
198
  /**
198
199
  * The action which was called from the adapter.
199
200
  */
200
- action: "create" | "update" | "findOne" | "findMany" | "updateMany" | "delete" | "deleteMany" | "count";
201
+ action: "create" | "update" | "findOne" | "findMany" | "updateMany" | "delete" | "deleteMany" | "consumeOne" | "count";
201
202
  /**
202
203
  * The model name.
203
204
  */
@@ -415,6 +416,26 @@ type DBAdapter<Options extends BetterAuthOptions = BetterAuthOptions> = {
415
416
  model: string;
416
417
  where: Where[];
417
418
  }) => Promise<number>;
419
+ /**
420
+ * Atomically consume a single row matching the where clause: delete it and
421
+ * return the deleted row, or return `null` if no row matched.
422
+ * Implementations MUST NOT delete any additional rows that also match a
423
+ * non-unique predicate.
424
+ *
425
+ * Under concurrent invocation against the same row, exactly one caller
426
+ * receives the row; subsequent racers receive `null`. This is the
427
+ * race-safe primitive for consuming single-use credentials
428
+ * (verification tokens, authorization codes, one-time tokens).
429
+ *
430
+ * Always defined on the factory-wrapped adapter. When the underlying
431
+ * `CustomAdapter` does not implement `consumeOne`, the factory provides
432
+ * a fallback that wraps `findMany + deleteMany` in `transaction(...)`
433
+ * and returns the row only when the delete reports an affected row.
434
+ */
435
+ consumeOne: <T>(data: {
436
+ model: string;
437
+ where: Where[];
438
+ }) => Promise<T | null>;
418
439
  /**
419
440
  * Execute multiple operations in a transaction.
420
441
  * If the adapter doesn't support transactions, operations will be executed sequentially.
@@ -496,6 +517,19 @@ interface CustomAdapter {
496
517
  model: string;
497
518
  where: CleanedWhere[];
498
519
  }) => Promise<number>;
520
+ /**
521
+ * Optional native atomic single-row consume. When omitted, the adapter
522
+ * factory falls back to `transaction(findMany + deleteMany)`.
523
+ * Implementing this method natively (e.g. `DELETE ... RETURNING *`,
524
+ * `findOneAndDelete`, `OUTPUT deleted.*`) gives one round trip and the
525
+ * strongest race-safety guarantee. Implementations must delete at most
526
+ * one matching row. TODO(consume-one-required): tighten to required in the
527
+ * next minor on `next`.
528
+ */
529
+ consumeOne?: <T>(data: {
530
+ model: string;
531
+ where: CleanedWhere[];
532
+ }) => Promise<T | null>;
499
533
  count: ({
500
534
  model,
501
535
  where
@@ -94,7 +94,7 @@ type AdapterFactoryCustomizeAdapterCreator = (config: {
94
94
  }: {
95
95
  where: W;
96
96
  model: string;
97
- action: "create" | "update" | "findOne" | "findMany" | "updateMany" | "delete" | "deleteMany" | "count";
97
+ action: "create" | "update" | "findOne" | "findMany" | "updateMany" | "delete" | "deleteMany" | "consumeOne" | "count";
98
98
  }) => W extends undefined ? undefined : CleanedWhere[];
99
99
  }) => CustomAdapter;
100
100
  type AdapterTestDebugLogs = {
@@ -141,6 +141,18 @@ interface SecondaryStorage {
141
141
  * @returns - Value of the key
142
142
  */
143
143
  get: (key: string) => Awaitable<unknown>;
144
+ /**
145
+ * Atomically get a value and delete it from storage.
146
+ *
147
+ * This is optional for backwards compatibility with existing secondary
148
+ * storage implementations. Single-use credential consumers use it when
149
+ * present to avoid a read-then-delete race.
150
+ *
151
+ * TODO(secondary-storage-atomic-consume): make this required in the next
152
+ * breaking release, or require database-backed verification storage for
153
+ * security-sensitive consume paths.
154
+ */
155
+ getAndDelete?: (key: string) => Awaitable<unknown>;
144
156
  set: (
145
157
  /**
146
158
  * Key to store
@@ -36,6 +36,7 @@ declare const BASE_ERROR_CODES: {
36
36
  USER_ALREADY_EXISTS: RawError<"USER_ALREADY_EXISTS">;
37
37
  USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL: RawError<"USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL">;
38
38
  EMAIL_CAN_NOT_BE_UPDATED: RawError<"EMAIL_CAN_NOT_BE_UPDATED">;
39
+ CHANGE_EMAIL_DISABLED: RawError<"CHANGE_EMAIL_DISABLED">;
39
40
  CREDENTIAL_ACCOUNT_NOT_FOUND: RawError<"CREDENTIAL_ACCOUNT_NOT_FOUND">;
40
41
  ACCOUNT_NOT_FOUND: RawError<"ACCOUNT_NOT_FOUND">;
41
42
  SESSION_EXPIRED: RawError<"SESSION_EXPIRED">;
@@ -23,6 +23,7 @@ const BASE_ERROR_CODES = defineErrorCodes({
23
23
  USER_ALREADY_EXISTS: "User already exists.",
24
24
  USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL: "User already exists. Use another email.",
25
25
  EMAIL_CAN_NOT_BE_UPDATED: "Email can not be updated",
26
+ CHANGE_EMAIL_DISABLED: "Change email is disabled",
26
27
  CREDENTIAL_ACCOUNT_NOT_FOUND: "Credential account not found",
27
28
  SESSION_EXPIRED: "Session expired. Re-authenticate to perform this action.",
28
29
  FAILED_TO_UNLINK_LAST_ACCOUNT: "You can't unlink your last account",
@@ -2,7 +2,7 @@ import { ATTR_HTTP_RESPONSE_STATUS_CODE } from "./attributes.mjs";
2
2
  import { getOpenTelemetryAPI } from "./api.mjs";
3
3
  //#region src/instrumentation/tracer.ts
4
4
  const INSTRUMENTATION_SCOPE = "better-auth";
5
- const INSTRUMENTATION_VERSION = "1.6.10";
5
+ const INSTRUMENTATION_VERSION = "1.6.11";
6
6
  /**
7
7
  * Better-auth uses `throw ctx.redirect(url)` for flow control (e.g. OAuth
8
8
  * callbacks). These are APIErrors with 3xx status codes and should not be
@@ -114,6 +114,18 @@ interface InternalAdapter<_Options extends BetterAuthOptions = BetterAuthOptions
114
114
  createVerificationValue(data: Omit<Verification, "createdAt" | "id" | "updatedAt"> & Partial<Verification>): Promise<Verification>;
115
115
  findVerificationValue(identifier: string): Promise<Verification | null>;
116
116
  deleteVerificationByIdentifier(identifier: string): Promise<void>;
117
+ /**
118
+ * Atomically consume a single-use verification row by `identifier` and
119
+ * return it. Only the first concurrent caller receives the latest row;
120
+ * subsequent callers receive `null`. Consuming one row invalidates the
121
+ * whole identifier so stale rows cannot be replayed. Callers MUST gate any
122
+ * state change (issue session, mint token, change password) on a non-null
123
+ * result.
124
+ *
125
+ * Replaces the racy `findVerificationValue` + `deleteVerificationByIdentifier`
126
+ * pair at single-use credential consumption sites.
127
+ */
128
+ consumeVerificationValue(identifier: string): Promise<Verification | null>;
117
129
  updateVerificationByIdentifier(identifier: string, data: Partial<Verification>): Promise<Verification>;
118
130
  refreshUserSessions(user: User): Promise<void>;
119
131
  }
@@ -157,12 +157,13 @@ type BetterAuthAdvancedOptions = {
157
157
  */
158
158
  disableIpTracking?: boolean;
159
159
  /**
160
- * IPv6 subnet prefix length for rate limiting.
161
- * IPv6 addresses will be normalized to this subnet.
160
+ * IPv6 prefix length used to collapse addresses before rate-limit keying.
161
+ * Any integer from 0 to 128 is accepted; common values are 32, 48, 56, 64, 128.
162
+ * Out-of-range values fall back to safe behavior (negative -> mask all, > 128 -> no mask).
162
163
  *
163
164
  * @default 64
164
165
  */
165
- ipv6Subnet?: 128 | 64 | 48 | 32;
166
+ ipv6Subnet?: number;
166
167
  } | undefined;
167
168
  /**
168
169
  * Force cookies to always use the `Secure` attribute. By default,
@@ -906,6 +907,25 @@ type BetterAuthOptions = {
906
907
  * @default false
907
908
  */
908
909
  disableImplicitLinking?: boolean;
910
+ /**
911
+ * Require the existing local user row to have
912
+ * `emailVerified: true` before implicit account linking
913
+ * uses the IdP's `email_verified` claim as ownership
914
+ * proof. Defaults to `true` so an attacker who
915
+ * pre-registers an unverified account at a victim's
916
+ * email cannot have the victim's OAuth identity linked
917
+ * into the attacker-owned row on first sign-in. Set to
918
+ * `false` for backward compatibility on apps whose
919
+ * users sign up via OAuth without verifying their email
920
+ * locally; understand the takeover risk before doing
921
+ * so.
922
+ *
923
+ * @default true
924
+ *
925
+ * @deprecated The option will be removed on the next
926
+ * minor; the gate will become unconditional.
927
+ */
928
+ requireLocalEmailVerified?: boolean;
909
929
  /**
910
930
  * List of trusted providers. Can be a static array or a function
911
931
  * that returns providers dynamically. The function is called
@@ -10,12 +10,13 @@
10
10
  */
11
11
  interface NormalizeIPOptions {
12
12
  /**
13
- * For IPv6 addresses, extract the subnet prefix instead of full address.
14
- * Common values: 32, 48, 64, 128 (default: 128 = full address)
13
+ * Prefix length used to collapse IPv6 addresses before keying.
14
+ * Any integer from 0 to 128 is accepted. Common values: 32, 48, 56, 64, 128.
15
+ * Values outside 0-128 are clamped.
15
16
  *
16
- * @default 128
17
+ * @default 64
17
18
  */
18
- ipv6Subnet?: 128 | 64 | 48 | 32;
19
+ ipv6Subnet?: number;
19
20
  }
20
21
  /**
21
22
  * Checks if an IP is valid IPv4 or IPv6
package/dist/utils/ip.mjs CHANGED
@@ -60,8 +60,8 @@ function expandIPv6(ipv6) {
60
60
  */
61
61
  function normalizeIPv6(ipv6, subnetPrefix) {
62
62
  const groups = expandIPv6(ipv6);
63
- if (subnetPrefix && subnetPrefix < 128) {
64
- let bitsRemaining = subnetPrefix;
63
+ if (subnetPrefix !== void 0 && subnetPrefix < 128) {
64
+ let bitsRemaining = Math.max(0, Math.floor(subnetPrefix));
65
65
  return groups.map((group) => {
66
66
  if (bitsRemaining <= 0) return "0000";
67
67
  if (bitsRemaining >= 16) {
@@ -99,7 +99,7 @@ function normalizeIP(ip, options = {}) {
99
99
  if (!isIPv6(ip)) return ip.toLowerCase();
100
100
  const ipv4 = extractIPv4FromMapped(ip);
101
101
  if (ipv4) return ipv4.toLowerCase();
102
- return normalizeIPv6(ip, options.ipv6Subnet || 64);
102
+ return normalizeIPv6(ip, options.ipv6Subnet ?? 64);
103
103
  }
104
104
  /**
105
105
  * Creates a rate limit key from IP and path
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@better-auth/core",
3
- "version": "1.6.10",
3
+ "version": "1.6.11",
4
4
  "description": "The most comprehensive authentication framework for TypeScript.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -93,12 +93,13 @@
93
93
  "./instrumentation": {
94
94
  "dev-source": "./src/instrumentation/index.ts",
95
95
  "types": "./dist/instrumentation/index.d.mts",
96
+ "workerd": "./dist/instrumentation/pure.index.mjs",
97
+ "edge": "./dist/instrumentation/pure.index.mjs",
98
+ "browser": "./dist/instrumentation/pure.index.mjs",
96
99
  "node": "./dist/instrumentation/index.mjs",
97
100
  "deno": "./dist/instrumentation/index.mjs",
98
101
  "bun": "./dist/instrumentation/index.mjs",
99
- "edge": "./dist/instrumentation/pure.index.mjs",
100
- "workerd": "./dist/instrumentation/pure.index.mjs",
101
- "browser": "./dist/instrumentation/pure.index.mjs",
102
+ "import": "./dist/instrumentation/index.mjs",
102
103
  "default": "./dist/instrumentation/index.mjs"
103
104
  }
104
105
  },
@@ -159,7 +160,7 @@
159
160
  "better-call": "1.3.5",
160
161
  "@cloudflare/workers-types": "^4.20250121.0",
161
162
  "jose": "^6.1.3",
162
- "kysely": "^0.28.14",
163
+ "kysely": "^0.28.17",
163
164
  "nanostores": "^1.1.1",
164
165
  "tsdown": "0.21.1"
165
166
  },
@@ -133,6 +133,11 @@ export const createAdapterFactory =
133
133
  !config.debugLogs.deleteMany
134
134
  ) {
135
135
  return;
136
+ } else if (
137
+ method === "consumeOne" &&
138
+ !config.debugLogs.consumeOne
139
+ ) {
140
+ return;
136
141
  } else if (method === "count" && !config.debugLogs.count) {
137
142
  return;
138
143
  }
@@ -485,6 +490,7 @@ export const createAdapterFactory =
485
490
  | "updateMany"
486
491
  | "delete"
487
492
  | "deleteMany"
493
+ | "consumeOne"
488
494
  | "count";
489
495
  }): W extends undefined ? undefined : CleanedWhere[] => {
490
496
  if (!where) return undefined as any;
@@ -1312,6 +1318,112 @@ export const createAdapterFactory =
1312
1318
  );
1313
1319
  return res;
1314
1320
  },
1321
+ consumeOne: async <T>({
1322
+ model: unsafeModel,
1323
+ where: unsafeWhere,
1324
+ }: {
1325
+ model: string;
1326
+ where: Where[];
1327
+ }): Promise<T | null> => {
1328
+ transactionId++;
1329
+ const thisTransactionId = transactionId;
1330
+ const model = getModelName(unsafeModel);
1331
+ const where = transformWhereClause({
1332
+ model: unsafeModel,
1333
+ where: unsafeWhere,
1334
+ action: "consumeOne",
1335
+ });
1336
+ unsafeModel = getDefaultModelName(unsafeModel);
1337
+ debugLog(
1338
+ { method: "consumeOne" },
1339
+ `${formatTransactionId(thisTransactionId)} ${formatStep(1, 3)}`,
1340
+ `${formatMethod("consumeOne")} ${formatAction("ConsumeOne")}:`,
1341
+ { model, where },
1342
+ );
1343
+
1344
+ let res: T | null;
1345
+ let resultNeedsOutputTransform = true;
1346
+ if (adapterInstance.consumeOne) {
1347
+ res = await withSpan(
1348
+ `db consumeOne ${model}`,
1349
+ {
1350
+ [ATTR_DB_OPERATION_NAME]: "consumeOne",
1351
+ [ATTR_DB_COLLECTION_NAME]: model,
1352
+ },
1353
+ () => adapterInstance.consumeOne!<T>({ model, where }),
1354
+ );
1355
+ } else {
1356
+ // TODO(consume-one-required): adapters without native `consumeOne`
1357
+ // fall back to `transaction(findMany + deleteMany)`. Race-safe on
1358
+ // engines with real transaction isolation; race window narrows
1359
+ // (does not close) on adapters that fall through to sequential
1360
+ // execution. Remove this branch when consumeOne becomes required.
1361
+ // FIXME(consume-one-nested-transaction): custom adapters without a
1362
+ // native consumeOne have no portable signal for "already inside a
1363
+ // transaction". First-party adapters mark transaction-scoped
1364
+ // adapters as as-is; make that capability explicit in the next
1365
+ // breaking adapter contract.
1366
+ res = await withSpan(
1367
+ `db consumeOne ${model}`,
1368
+ {
1369
+ [ATTR_DB_OPERATION_NAME]: "consumeOne",
1370
+ [ATTR_DB_COLLECTION_NAME]: model,
1371
+ },
1372
+ () =>
1373
+ adapter.transaction(async (trx) => {
1374
+ const rows = await trx.findMany<Record<string, any>>({
1375
+ model: unsafeModel,
1376
+ where: unsafeWhere,
1377
+ limit: 1,
1378
+ });
1379
+ const target = rows[0];
1380
+ if (!target) return null;
1381
+ const deleted = await trx.deleteMany({
1382
+ model: unsafeModel,
1383
+ where: [
1384
+ ...unsafeWhere,
1385
+ {
1386
+ field: "id",
1387
+ value: target.id,
1388
+ operator: "eq",
1389
+ connector: "AND",
1390
+ mode: "sensitive",
1391
+ },
1392
+ ],
1393
+ });
1394
+ return deleted > 0 ? (target as T) : null;
1395
+ }),
1396
+ );
1397
+ resultNeedsOutputTransform = false;
1398
+ }
1399
+
1400
+ debugLog(
1401
+ { method: "consumeOne" },
1402
+ `${formatTransactionId(thisTransactionId)} ${formatStep(2, 3)}`,
1403
+ `${formatMethod("consumeOne")} ${formatAction("DB Result")}:`,
1404
+ { model, data: res },
1405
+ );
1406
+ let transformed: any = res;
1407
+ if (
1408
+ !config.disableTransformOutput &&
1409
+ resultNeedsOutputTransform &&
1410
+ res
1411
+ ) {
1412
+ transformed = await transformOutput(
1413
+ res as Record<string, any>,
1414
+ unsafeModel,
1415
+ undefined,
1416
+ undefined,
1417
+ );
1418
+ }
1419
+ debugLog(
1420
+ { method: "consumeOne" },
1421
+ `${formatTransactionId(thisTransactionId)} ${formatStep(3, 3)}`,
1422
+ `${formatMethod("consumeOne")} ${formatAction("Parsed Result")}:`,
1423
+ { model, data: transformed },
1424
+ );
1425
+ return transformed as T | null;
1426
+ },
1315
1427
  count: async ({
1316
1428
  model: unsafeModel,
1317
1429
  where: unsafeWhere,
@@ -15,6 +15,7 @@ export type DBAdapterDebugLogOption =
15
15
  findMany?: boolean | undefined;
16
16
  delete?: boolean | undefined;
17
17
  deleteMany?: boolean | undefined;
18
+ consumeOne?: boolean | undefined;
18
19
  count?: boolean | undefined;
19
20
  }
20
21
  | {
@@ -211,6 +212,7 @@ export interface DBAdapterFactoryConfig<
211
212
  | "updateMany"
212
213
  | "delete"
213
214
  | "deleteMany"
215
+ | "consumeOne"
214
216
  | "count";
215
217
  /**
216
218
  * The model name.
@@ -445,6 +447,23 @@ export type DBAdapter<Options extends BetterAuthOptions = BetterAuthOptions> = {
445
447
  }) => Promise<number>;
446
448
  delete: <_T>(data: { model: string; where: Where[] }) => Promise<void>;
447
449
  deleteMany: (data: { model: string; where: Where[] }) => Promise<number>;
450
+ /**
451
+ * Atomically consume a single row matching the where clause: delete it and
452
+ * return the deleted row, or return `null` if no row matched.
453
+ * Implementations MUST NOT delete any additional rows that also match a
454
+ * non-unique predicate.
455
+ *
456
+ * Under concurrent invocation against the same row, exactly one caller
457
+ * receives the row; subsequent racers receive `null`. This is the
458
+ * race-safe primitive for consuming single-use credentials
459
+ * (verification tokens, authorization codes, one-time tokens).
460
+ *
461
+ * Always defined on the factory-wrapped adapter. When the underlying
462
+ * `CustomAdapter` does not implement `consumeOne`, the factory provides
463
+ * a fallback that wraps `findMany + deleteMany` in `transaction(...)`
464
+ * and returns the row only when the delete reports an affected row.
465
+ */
466
+ consumeOne: <T>(data: { model: string; where: Where[] }) => Promise<T | null>;
448
467
  /**
449
468
  * Execute multiple operations in a transaction.
450
469
  * If the adapter doesn't support transactions, operations will be executed sequentially.
@@ -531,6 +550,19 @@ export interface CustomAdapter {
531
550
  model: string;
532
551
  where: CleanedWhere[];
533
552
  }) => Promise<number>;
553
+ /**
554
+ * Optional native atomic single-row consume. When omitted, the adapter
555
+ * factory falls back to `transaction(findMany + deleteMany)`.
556
+ * Implementing this method natively (e.g. `DELETE ... RETURNING *`,
557
+ * `findOneAndDelete`, `OUTPUT deleted.*`) gives one round trip and the
558
+ * strongest race-safety guarantee. Implementations must delete at most
559
+ * one matching row. TODO(consume-one-required): tighten to required in the
560
+ * next minor on `next`.
561
+ */
562
+ consumeOne?: <T>(data: {
563
+ model: string;
564
+ where: CleanedWhere[];
565
+ }) => Promise<T | null>;
534
566
  count: ({
535
567
  model,
536
568
  where,
@@ -122,6 +122,7 @@ export type AdapterFactoryCustomizeAdapterCreator = (config: {
122
122
  | "updateMany"
123
123
  | "delete"
124
124
  | "deleteMany"
125
+ | "consumeOne"
125
126
  | "count";
126
127
  }) => W extends undefined ? undefined : CleanedWhere[];
127
128
  }) => CustomAdapter;
package/src/db/type.ts CHANGED
@@ -311,6 +311,18 @@ export interface SecondaryStorage {
311
311
  * @returns - Value of the key
312
312
  */
313
313
  get: (key: string) => Awaitable<unknown>;
314
+ /**
315
+ * Atomically get a value and delete it from storage.
316
+ *
317
+ * This is optional for backwards compatibility with existing secondary
318
+ * storage implementations. Single-use credential consumers use it when
319
+ * present to avoid a read-then-delete race.
320
+ *
321
+ * TODO(secondary-storage-atomic-consume): make this required in the next
322
+ * breaking release, or require database-backed verification storage for
323
+ * security-sensitive consume paths.
324
+ */
325
+ getAndDelete?: (key: string) => Awaitable<unknown>;
314
326
  set: (
315
327
  /**
316
328
  * Key to store
@@ -37,6 +37,7 @@ export const BASE_ERROR_CODES = defineErrorCodes({
37
37
  USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL:
38
38
  "User already exists. Use another email.",
39
39
  EMAIL_CAN_NOT_BE_UPDATED: "Email can not be updated",
40
+ CHANGE_EMAIL_DISABLED: "Change email is disabled",
40
41
  CREDENTIAL_ACCOUNT_NOT_FOUND: "Credential account not found",
41
42
  SESSION_EXPIRED: "Session expired. Re-authenticate to perform this action.",
42
43
  FAILED_TO_UNLINK_LAST_ACCOUNT: "You can't unlink your last account",
@@ -216,6 +216,19 @@ export interface InternalAdapter<
216
216
 
217
217
  deleteVerificationByIdentifier(identifier: string): Promise<void>;
218
218
 
219
+ /**
220
+ * Atomically consume a single-use verification row by `identifier` and
221
+ * return it. Only the first concurrent caller receives the latest row;
222
+ * subsequent callers receive `null`. Consuming one row invalidates the
223
+ * whole identifier so stale rows cannot be replayed. Callers MUST gate any
224
+ * state change (issue session, mint token, change password) on a non-null
225
+ * result.
226
+ *
227
+ * Replaces the racy `findVerificationValue` + `deleteVerificationByIdentifier`
228
+ * pair at single-use credential consumption sites.
229
+ */
230
+ consumeVerificationValue(identifier: string): Promise<Verification | null>;
231
+
219
232
  updateVerificationByIdentifier(
220
233
  identifier: string,
221
234
  data: Partial<Verification>,
@@ -207,12 +207,13 @@ export type BetterAuthAdvancedOptions = {
207
207
  */
208
208
  disableIpTracking?: boolean;
209
209
  /**
210
- * IPv6 subnet prefix length for rate limiting.
211
- * IPv6 addresses will be normalized to this subnet.
210
+ * IPv6 prefix length used to collapse addresses before rate-limit keying.
211
+ * Any integer from 0 to 128 is accepted; common values are 32, 48, 56, 64, 128.
212
+ * Out-of-range values fall back to safe behavior (negative -> mask all, > 128 -> no mask).
212
213
  *
213
214
  * @default 64
214
215
  */
215
- ipv6Subnet?: 128 | 64 | 48 | 32;
216
+ ipv6Subnet?: number;
216
217
  }
217
218
  | undefined;
218
219
  /**
@@ -1020,6 +1021,25 @@ export type BetterAuthOptions = {
1020
1021
  * @default false
1021
1022
  */
1022
1023
  disableImplicitLinking?: boolean;
1024
+ /**
1025
+ * Require the existing local user row to have
1026
+ * `emailVerified: true` before implicit account linking
1027
+ * uses the IdP's `email_verified` claim as ownership
1028
+ * proof. Defaults to `true` so an attacker who
1029
+ * pre-registers an unverified account at a victim's
1030
+ * email cannot have the victim's OAuth identity linked
1031
+ * into the attacker-owned row on first sign-in. Set to
1032
+ * `false` for backward compatibility on apps whose
1033
+ * users sign up via OAuth without verifying their email
1034
+ * locally; understand the takeover risk before doing
1035
+ * so.
1036
+ *
1037
+ * @default true
1038
+ *
1039
+ * @deprecated The option will be removed on the next
1040
+ * minor; the gate will become unconditional.
1041
+ */
1042
+ requireLocalEmailVerified?: boolean;
1023
1043
  /**
1024
1044
  * List of trusted providers. Can be a static array or a function
1025
1045
  * that returns providers dynamically. The function is called
package/src/utils/ip.ts CHANGED
@@ -12,12 +12,13 @@ import * as z from "zod";
12
12
 
13
13
  interface NormalizeIPOptions {
14
14
  /**
15
- * For IPv6 addresses, extract the subnet prefix instead of full address.
16
- * Common values: 32, 48, 64, 128 (default: 128 = full address)
15
+ * Prefix length used to collapse IPv6 addresses before keying.
16
+ * Any integer from 0 to 128 is accepted. Common values: 32, 48, 56, 64, 128.
17
+ * Values outside 0-128 are clamped.
17
18
  *
18
- * @default 128
19
+ * @default 64
19
20
  */
20
- ipv6Subnet?: 128 | 64 | 48 | 32;
21
+ ipv6Subnet?: number;
21
22
  }
22
23
 
23
24
  /**
@@ -117,15 +118,13 @@ function expandIPv6(ipv6: string): string[] {
117
118
  * Normalizes an IPv6 address to canonical form
118
119
  * e.g., "2001:DB8::1" -> "2001:0db8:0000:0000:0000:0000:0000:0001"
119
120
  */
120
- function normalizeIPv6(
121
- ipv6: string,
122
- subnetPrefix?: 128 | 32 | 48 | 64,
123
- ): string {
121
+ function normalizeIPv6(ipv6: string, subnetPrefix?: number): string {
124
122
  const groups = expandIPv6(ipv6);
125
123
 
126
- if (subnetPrefix && subnetPrefix < 128) {
127
- // Apply subnet mask
128
- const prefix = subnetPrefix;
124
+ if (subnetPrefix !== undefined && subnetPrefix < 128) {
125
+ // Clamp to a valid bit range so out-of-spec inputs degrade safely:
126
+ // negative or fractional values would otherwise produce malformed masks.
127
+ const prefix = Math.max(0, Math.floor(subnetPrefix));
129
128
  let bitsRemaining: number = prefix;
130
129
 
131
130
  const maskedGroups = groups.map((group) => {
@@ -191,8 +190,8 @@ export function normalizeIP(
191
190
  return ipv4.toLowerCase();
192
191
  }
193
192
 
194
- // Normalize IPv6
195
- const subnetPrefix = options.ipv6Subnet || 64;
193
+ // Normalize IPv6. Use ?? so an explicit 0 (mask-all) is honoured.
194
+ const subnetPrefix = options.ipv6Subnet ?? 64;
196
195
  return normalizeIPv6(ip, subnetPrefix);
197
196
  }
198
197