better-auth 1.7.7 → 1.7.8

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.
@@ -1,7 +1,7 @@
1
1
  import { originCheck } from "../middlewares/origin-check.mjs";
2
2
  import { getDate } from "../../utils/date.mjs";
3
3
  import { sensitiveSessionMiddleware } from "./session.mjs";
4
- import { assertPasswordNotTooLong, assertPasswordNotTooShort, validatePassword } from "../../utils/password.mjs";
4
+ import { assertPasswordNotTooLong, assertPasswordNotTooShort, resetCredentialPassword, validatePassword } from "../../utils/password.mjs";
5
5
  import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
6
6
  import { generateId } from "@better-auth/core/utils/id";
7
7
  import { createAuthEndpoint } from "@better-auth/core/api";
@@ -152,21 +152,15 @@ const resetPassword = createAuthEndpoint("/reset-password", {
152
152
  assertPasswordNotTooShort(ctx, newPassword);
153
153
  assertPasswordNotTooLong(ctx, newPassword);
154
154
  const id = `reset-password:${token}`;
155
- const verification = await ctx.context.internalAdapter.consumeVerificationValue(id);
156
- if (!verification) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.INVALID_TOKEN);
155
+ const verification = await ctx.context.internalAdapter.findVerificationValue(id);
156
+ if (!verification || verification.expiresAt < /* @__PURE__ */ new Date()) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.INVALID_TOKEN);
157
157
  const userId = verification.value;
158
158
  const user = await ctx.context.internalAdapter.findUserById(userId);
159
159
  if (!user) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.USER_NOT_FOUND);
160
- const hashedPassword = await ctx.context.password.hash(newPassword);
161
- if (!await ctx.context.internalAdapter.findCredentialAccount(userId)) await ctx.context.internalAdapter.createAccount({
162
- userId,
163
- providerId: "credential",
164
- accountId: user.id,
165
- password: hashedPassword
160
+ await resetCredentialPassword(ctx, userId, await ctx.context.password.hash(newPassword), async () => {
161
+ if (!await ctx.context.internalAdapter.consumeVerificationValue(id)) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.INVALID_TOKEN);
166
162
  });
167
- else await ctx.context.internalAdapter.updatePassword(userId, hashedPassword);
168
163
  if (ctx.context.options.emailAndPassword?.onPasswordReset) await ctx.context.options.emailAndPassword.onPasswordReset({ user }, ctx.request);
169
- if (ctx.context.options.emailAndPassword?.revokeSessionsOnPasswordReset) await ctx.context.internalAdapter.deleteUserSessions(userId);
170
164
  return ctx.json({ status: true });
171
165
  });
172
166
  const verifyPassword = createAuthEndpoint("/verify-password", {
@@ -10,7 +10,6 @@ import { generateIdTokenNonce, generateState } from "../../oauth2/state.mjs";
10
10
  import { safeCloneRequest } from "../../utils/request.mjs";
11
11
  import { createEmailVerificationToken } from "./email-verification.mjs";
12
12
  import { assertPasswordNotTooLong } from "../../utils/password.mjs";
13
- import "../../utils/index.mjs";
14
13
  import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
15
14
  import { additionalAuthorizationParamsSchema, supportsIdTokenSignIn, verifyProviderIdToken } from "@better-auth/core/oauth2";
16
15
  import { SocialProviderListEnum } from "@better-auth/core/social-providers";
@@ -252,7 +252,7 @@ const signUpEmail = () => createAuthEndpoint("/sign-up/email", {
252
252
  token: null,
253
253
  user: parseUserOutput(ctx.context.options, createdUser)
254
254
  });
255
- const session = await ctx.context.internalAdapter.createSession(createdUser.id, rememberMe === false);
255
+ const session = await ctx.context.internalAdapter.createSession(createdUser.id, rememberMe === false, void 0, void 0, { deferSecondaryStorageWrites: true });
256
256
  if (!session) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.FAILED_TO_CREATE_SESSION);
257
257
  await setSessionCookie(ctx, {
258
258
  session,
@@ -5,6 +5,7 @@ import { deleteSessionCookie, setSessionCookie } from "../../cookies/index.mjs";
5
5
  import { getSessionFromCtx, isStateful, sensitiveSessionMiddleware, sessionMiddleware } from "./session.mjs";
6
6
  import { createEmailVerificationToken } from "./email-verification.mjs";
7
7
  import { assertPasswordNotTooLong, assertPasswordNotTooShort } from "../../utils/password.mjs";
8
+ import { runWithTransaction } from "@better-auth/core/context";
8
9
  import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
9
10
  import { createAuthEndpoint } from "@better-auth/core/api";
10
11
  import * as z from "zod";
@@ -169,12 +170,16 @@ const changePassword = createAuthEndpoint("/change-password", {
169
170
  hash: account.password,
170
171
  password: currentPassword
171
172
  })) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.INVALID_PASSWORD);
172
- await ctx.context.internalAdapter.updateAccount(account.id, { password: passwordHash });
173
+ const newSession = await runWithTransaction(ctx.context.adapter, async () => {
174
+ if (revokeOtherSessions) await ctx.context.internalAdapter.deleteUserSessions(session.user.id);
175
+ await ctx.context.internalAdapter.updateAccount(account.id, { password: passwordHash });
176
+ if (!revokeOtherSessions) return null;
177
+ const created = await ctx.context.internalAdapter.createSession(session.user.id, void 0, void 0, void 0, { deferSecondaryStorageWrites: true });
178
+ if (!created) throw APIError.from("INTERNAL_SERVER_ERROR", BASE_ERROR_CODES.FAILED_TO_GET_SESSION);
179
+ return created;
180
+ });
173
181
  let token = null;
174
- if (revokeOtherSessions) {
175
- await ctx.context.internalAdapter.deleteUserSessions(session.user.id);
176
- const newSession = await ctx.context.internalAdapter.createSession(session.user.id);
177
- if (!newSession) throw APIError.from("INTERNAL_SERVER_ERROR", BASE_ERROR_CODES.FAILED_TO_GET_SESSION);
182
+ if (newSession) {
178
183
  await setSessionCookie(ctx, {
179
184
  session: newSession,
180
185
  user: session.user
@@ -10,7 +10,7 @@ import { JSONWebKeySet } from "jose";
10
10
  declare function createCookieGetter(options: BetterAuthOptions): (cookieName: string, overrideAttributes?: Partial<CookieOptions>) => {
11
11
  name: string;
12
12
  attributes: {
13
- domain?: string;
13
+ domain?: string | undefined;
14
14
  expires?: Date;
15
15
  httpOnly: boolean;
16
16
  maxAge?: number;
@@ -25,7 +25,7 @@ declare function getCookies(options: BetterAuthOptions): {
25
25
  sessionToken: {
26
26
  name: string;
27
27
  attributes: {
28
- domain?: string;
28
+ domain?: string | undefined;
29
29
  expires?: Date;
30
30
  httpOnly: boolean;
31
31
  maxAge?: number;
@@ -43,7 +43,7 @@ declare function getCookies(options: BetterAuthOptions): {
43
43
  sessionData: {
44
44
  name: string;
45
45
  attributes: {
46
- domain?: string;
46
+ domain?: string | undefined;
47
47
  expires?: Date;
48
48
  httpOnly: boolean;
49
49
  maxAge?: number;
@@ -57,7 +57,7 @@ declare function getCookies(options: BetterAuthOptions): {
57
57
  dontRememberToken: {
58
58
  name: string;
59
59
  attributes: {
60
- domain?: string;
60
+ domain?: string | undefined;
61
61
  expires?: Date;
62
62
  httpOnly: boolean;
63
63
  maxAge?: number;
@@ -71,7 +71,7 @@ declare function getCookies(options: BetterAuthOptions): {
71
71
  accountData: {
72
72
  name: string;
73
73
  attributes: {
74
- domain?: string;
74
+ domain?: string | undefined;
75
75
  expires?: Date;
76
76
  httpOnly: boolean;
77
77
  maxAge?: number;
@@ -1,5 +1,4 @@
1
- import { getSchema } from "./get-schema.mjs";
2
- import { getAuthTables } from "@better-auth/core/db";
1
+ import { buildSchema } from "./get-schema.mjs";
3
2
  import { createLogger } from "@better-auth/core/env";
4
3
  import { BetterAuthError } from "@better-auth/core/error";
5
4
  import { createKyselyAdapter, getMssqlSchema, getPostgresSchema, toIntrospectedTables, toPhysicalSchema } from "@better-auth/kysely-adapter";
@@ -342,8 +341,7 @@ function matchType(columnDataType, fieldType, dbType) {
342
341
  * existing or already-planned index.
343
342
  */
344
343
  async function getMigrations(config, { throwOnUnsafe = true } = {}) {
345
- const betterAuthSchema = getSchema(config);
346
- const authTables = getAuthTables(config);
344
+ const { schema: betterAuthSchema, tables: authTables, referenceKeys } = buildSchema(config);
347
345
  const logger = createLogger(config.logger);
348
346
  const unsafeChanges = [];
349
347
  const reportUnsafeChange = (message) => {
@@ -562,14 +560,18 @@ async function getMigrations(config, { throwOnUnsafe = true } = {}) {
562
560
  schema: authTables,
563
561
  usePlural: false
564
562
  });
565
- function getReferencePath(model, field) {
563
+ function getReferencePath(field) {
564
+ const reference = field.references;
565
+ if (!reference) throw new BetterAuthError("Cannot resolve a field without references.");
566
+ const keys = referenceKeys.get(field);
567
+ if (!keys) throw new BetterAuthError(`Missing migration reference metadata for "${reference.model}.${reference.field}".`);
566
568
  try {
567
- return `${getModelName(model)}.${getFieldName({
568
- model,
569
- field
569
+ return `${getModelName(keys.modelKey)}.${getFieldName({
570
+ model: keys.modelKey,
571
+ field: keys.fieldKey
570
572
  })}`;
571
573
  } catch {
572
- return `${model}.${field}`;
574
+ return `${reference.model}.${reference.field}`;
573
575
  }
574
576
  }
575
577
  const deferredIndexes = [];
@@ -614,7 +616,7 @@ async function getMigrations(config, { throwOnUnsafe = true } = {}) {
614
616
  }
615
617
  const built = builder.addColumn(fieldName, type, (col) => {
616
618
  col = field.required !== false ? col.notNull() : col;
617
- if (field.references) col = col.references(getReferencePath(field.references.model, field.references.field)).onDelete(field.references.onDelete || "cascade");
619
+ if (field.references) col = col.references(getReferencePath(field)).onDelete(field.references.onDelete || "cascade");
618
620
  if (timestampDefault) if (dbType === "mysql") col = col.defaultTo(sql`CURRENT_TIMESTAMP(3)`);
619
621
  else col = col.defaultTo(sql`CURRENT_TIMESTAMP`);
620
622
  else if (staticDefault) col = col.defaultTo(typeof field.defaultValue === "boolean" && (dbType === "sqlite" || dbType === "mssql") ? field.defaultValue ? 1 : 0 : field.defaultValue);
@@ -642,7 +644,7 @@ async function getMigrations(config, { throwOnUnsafe = true } = {}) {
642
644
  const type = getType(field, fieldName, getTableIndexStringLength(table.table, fieldName));
643
645
  dbT = dbT.addColumn(fieldName, type, (col) => {
644
646
  col = field.required !== false ? col.notNull() : col;
645
- if (field.references) col = col.references(getReferencePath(field.references.model, field.references.field)).onDelete(field.references.onDelete || "cascade");
647
+ if (field.references) col = col.references(getReferencePath(field)).onDelete(field.references.onDelete || "cascade");
646
648
  if (field.unique) col = col.unique();
647
649
  if (field.type === "date" && typeof field.defaultValue === "function" && (dbType === "postgres" || dbType === "mysql" || dbType === "mssql")) if (dbType === "mysql") col = col.defaultTo(sql`CURRENT_TIMESTAMP(3)`);
648
650
  else col = col.defaultTo(sql`CURRENT_TIMESTAMP`);
@@ -1,22 +1,34 @@
1
1
  import { getAuthTablesWithResolvedIndexes } from "@better-auth/core/db/internal";
2
2
  //#region src/db/get-schema.ts
3
3
  function getSchema(config) {
4
+ return buildSchema(config).schema;
5
+ }
6
+ /**
7
+ * @internal
8
+ */
9
+ function buildSchema(config) {
4
10
  const { indexesByTable, tables } = getAuthTablesWithResolvedIndexes(config);
11
+ const referenceKeys = /* @__PURE__ */ new Map();
5
12
  const schema = {};
6
13
  for (const key in tables) {
7
14
  const table = tables[key];
8
15
  const fields = table.fields;
9
16
  const actualFields = {};
10
17
  Object.entries(fields).forEach(([key, field]) => {
11
- actualFields[field.fieldName || key] = field;
12
- if (field.references) {
13
- const refTable = tables[field.references.model];
14
- if (refTable) actualFields[field.fieldName || key].references = {
15
- ...field.references,
16
- model: refTable.modelName,
17
- field: field.references.field
18
- };
19
- }
18
+ const reference = field.references;
19
+ const refTable = reference ? tables[reference.model] : void 0;
20
+ const actualField = reference && refTable ? {
21
+ ...field,
22
+ references: {
23
+ ...reference,
24
+ model: refTable.modelName
25
+ }
26
+ } : field;
27
+ actualFields[field.fieldName || key] = actualField;
28
+ if (reference) referenceKeys.set(actualField, {
29
+ modelKey: reference.model,
30
+ fieldKey: reference.field
31
+ });
20
32
  });
21
33
  if (schema[table.modelName]) {
22
34
  schema[table.modelName].fields = {
@@ -33,7 +45,11 @@ function getSchema(config) {
33
45
  };
34
46
  }
35
47
  for (const [tableName, indexes] of indexesByTable) if (schema[tableName]) schema[tableName].indexes = indexes;
36
- return schema;
48
+ return {
49
+ schema,
50
+ tables,
51
+ referenceKeys
52
+ };
37
53
  }
38
54
  //#endregion
39
- export { getSchema };
55
+ export { buildSchema, getSchema };
@@ -7,8 +7,8 @@ import { getCurrentAdapter, getCurrentAuthEndpointContext, queueAfterTransaction
7
7
  import { APIError, BetterAuthError } from "@better-auth/core/error";
8
8
  import { generateId } from "@better-auth/core/utils/id";
9
9
  import { safeJSONParse } from "@better-auth/core/utils/json";
10
+ import { encodeDeterministicId } from "@better-auth/core/db/internal";
10
11
  import { getIP } from "@better-auth/core/utils/ip";
11
- import { base64Url } from "@better-auth/utils/base64";
12
12
  import { createHash } from "@better-auth/utils/hash";
13
13
  //#region src/db/internal-adapter.ts
14
14
  function getTTLSeconds(expiresAt, now = Date.now()) {
@@ -21,6 +21,7 @@ const createInternalAdapter = (adapter, ctx) => {
21
21
  const secondaryStorage = options.secondaryStorage;
22
22
  const verificationConsumeLocks = /* @__PURE__ */ new Map();
23
23
  const sessionExpiration = options.session?.expiresIn || 3600 * 24 * 7;
24
+ const databaseSessionFallbackEnabled = !!secondaryStorage && options.session?.storeSessionInDatabase === true && options.session?.preserveSessionInDatabase !== true;
24
25
  const { createWithHooks, updateWithHooks, updateManyWithHooks, deleteWithHooks, deleteManyWithHooks, consumeOneWithHooks } = getWithHooks(adapter, ctx);
25
26
  /**
26
27
  * Ends the live session rows matched by `where` without physically deleting
@@ -175,6 +176,17 @@ const createInternalAdapter = (adapter, ctx) => {
175
176
  }, "account", void 0);
176
177
  },
177
178
  listSessions: async (userId, options) => {
179
+ if (databaseSessionFallbackEnabled) return (await (await getCurrentAdapter(adapter)).findMany({
180
+ model: "session",
181
+ where: [{
182
+ field: "userId",
183
+ value: userId
184
+ }, {
185
+ field: "expiresAt",
186
+ value: /* @__PURE__ */ new Date(),
187
+ operator: "gt"
188
+ }]
189
+ })).map((session) => parseSessionOutput(ctx.options, session));
178
190
  if (secondaryStorage) {
179
191
  const currentList = await secondaryStorage.get(`active-sessions-${userId}`);
180
192
  if (!currentList) return [];
@@ -250,7 +262,7 @@ const createInternalAdapter = (adapter, ctx) => {
250
262
  return ctx?.headers || ctx?.request?.headers;
251
263
  })();
252
264
  const storeInDb = options.session?.storeSessionInDatabase;
253
- const databaseSessionFallbackEnabled = storeInDb === true && options.session?.preserveSessionInDatabase !== true;
265
+ const deferMirror = storageOptions?.deferSecondaryStorageWrites === true && databaseSessionFallbackEnabled;
254
266
  const { id: _, ...rest } = override || {};
255
267
  let sessionId;
256
268
  if (secondaryStorage && !storeInDb) {
@@ -305,18 +317,18 @@ const createInternalAdapter = (adapter, ctx) => {
305
317
  }), sessionTTL);
306
318
  return sessionData;
307
319
  };
308
- const res = await createWithHooks(data, "session", secondaryStorage ? {
320
+ return await createWithHooks(data, "session", secondaryStorage ? {
309
321
  fn: async (sessionData) => {
310
- return storageOptions?.deferSecondaryStorageWrites ? sessionData : mirrorSessionToSecondaryStorage(sessionData);
322
+ if (!deferMirror) return mirrorSessionToSecondaryStorage(sessionData);
323
+ await queueAfterTransactionHook(async () => {
324
+ await mirrorSessionToSecondaryStorage(sessionData);
325
+ }, { onError(error) {
326
+ logger.error("Failed to mirror committed session to secondary storage", error);
327
+ } });
328
+ return sessionData;
311
329
  },
312
330
  executeMainFn: storeInDb
313
331
  } : void 0);
314
- if (secondaryStorage && storageOptions?.deferSecondaryStorageWrites && res) await queueAfterTransactionHook(async () => {
315
- await mirrorSessionToSecondaryStorage(res);
316
- }, databaseSessionFallbackEnabled ? { onError(error) {
317
- logger.error("Failed to mirror committed session to secondary storage", error);
318
- } } : void 0);
319
- return res;
320
332
  },
321
333
  findSession: async (token) => {
322
334
  if (secondaryStorage) {
@@ -357,54 +369,57 @@ const createInternalAdapter = (adapter, ctx) => {
357
369
  };
358
370
  },
359
371
  findSessions: async (sessionTokens, options) => {
360
- if (secondaryStorage) {
361
- const sessions = [];
362
- for (const sessionToken of sessionTokens) {
363
- const sessionStringified = await secondaryStorage.get(sessionToken);
364
- if (sessionStringified) try {
365
- const s = typeof sessionStringified === "string" ? JSON.parse(sessionStringified) : sessionStringified;
366
- if (!s) continue;
367
- const expiresAt = new Date(s.session.expiresAt);
368
- if (options?.onlyActiveSessions && expiresAt <= /* @__PURE__ */ new Date()) continue;
369
- const session = {
370
- session: {
371
- ...s.session,
372
- expiresAt: new Date(s.session.expiresAt)
373
- },
374
- user: {
375
- ...s.user,
376
- createdAt: new Date(s.user.createdAt),
377
- updatedAt: new Date(s.user.updatedAt)
378
- }
379
- };
380
- sessions.push(session);
381
- } catch {
382
- continue;
383
- }
372
+ const uniqueTokens = [...new Set(sessionTokens)];
373
+ const sessionsByToken = /* @__PURE__ */ new Map();
374
+ const missingTokens = [];
375
+ if (secondaryStorage) for (const sessionToken of uniqueTokens) {
376
+ const sessionStringified = await secondaryStorage.get(sessionToken);
377
+ if (sessionStringified) try {
378
+ const s = typeof sessionStringified === "string" ? JSON.parse(sessionStringified) : sessionStringified;
379
+ if (!s) continue;
380
+ const expiresAt = new Date(s.session.expiresAt);
381
+ if (options?.onlyActiveSessions && expiresAt <= /* @__PURE__ */ new Date()) continue;
382
+ const session = {
383
+ session: {
384
+ ...s.session,
385
+ expiresAt: new Date(s.session.expiresAt)
386
+ },
387
+ user: {
388
+ ...s.user,
389
+ createdAt: new Date(s.user.createdAt),
390
+ updatedAt: new Date(s.user.updatedAt)
391
+ }
392
+ };
393
+ sessionsByToken.set(sessionToken, session);
394
+ } catch {
395
+ continue;
384
396
  }
385
- return sessions;
397
+ else missingTokens.push(sessionToken);
386
398
  }
387
- const sessions = await (await getCurrentAdapter(adapter)).findMany({
388
- model: "session",
389
- where: [{
390
- field: "token",
391
- value: sessionTokens,
392
- operator: "in"
393
- }, ...options?.onlyActiveSessions ? [{
394
- field: "expiresAt",
395
- value: /* @__PURE__ */ new Date(),
396
- operator: "gt"
397
- }] : []],
398
- join: { user: true }
399
- });
400
- if (!sessions.length) return [];
401
- if (sessions.some((session) => !session.user)) return [];
402
- return sessions.map((_session) => {
403
- const { user, ...session } = _session;
404
- return {
399
+ const databaseTokens = secondaryStorage ? missingTokens : uniqueTokens;
400
+ if (databaseTokens.length > 0 && (!secondaryStorage || databaseSessionFallbackEnabled)) {
401
+ const databaseSessions = await (await getCurrentAdapter(adapter)).findMany({
402
+ model: "session",
403
+ limit: databaseTokens.length,
404
+ where: [{
405
+ field: "token",
406
+ value: databaseTokens,
407
+ operator: "in"
408
+ }, ...options?.onlyActiveSessions ? [{
409
+ field: "expiresAt",
410
+ value: /* @__PURE__ */ new Date(),
411
+ operator: "gt"
412
+ }] : []],
413
+ join: { user: true }
414
+ });
415
+ for (const { user, ...session } of databaseSessions) if (user) sessionsByToken.set(session.token, {
405
416
  session,
406
417
  user
407
- };
418
+ });
419
+ }
420
+ return uniqueTokens.flatMap((token) => {
421
+ const session = sessionsByToken.get(token);
422
+ return session ? [session] : [];
408
423
  });
409
424
  },
410
425
  updateSession: async (sessionToken, session) => {
@@ -868,7 +883,9 @@ const createInternalAdapter = (adapter, ctx) => {
868
883
  * already taken.
869
884
  *
870
885
  * The `verification.identifier` column is non-unique, so uniqueness comes
871
- * from a deterministic primary key (`SHA-256` of `reserve:<identifier>`).
886
+ * from a deterministic primary key (`SHA-256` of `reserve:<identifier>`,
887
+ * encoded by `encodeDeterministicId` so the configured id strategy keeps
888
+ * it; see its note on `generateId: "serial"`).
872
889
  * The database path is atomic: the primary key turns the INSERT into the
873
890
  * first-writer-wins gate, and a duplicate is detected portably by
874
891
  * re-reading the row rather than matching adapter-specific errors.
@@ -882,7 +899,7 @@ const createInternalAdapter = (adapter, ctx) => {
882
899
  * is best-effort there (it is intended for development and tests).
883
900
  */
884
901
  reserveVerificationValue: async (data) => {
885
- const reservationId = base64Url.encode(new Uint8Array(await createHash("SHA-256").digest(new TextEncoder().encode("reserve:" + data.identifier))), { padding: false });
902
+ const reservationId = encodeDeterministicId(new Uint8Array(await createHash("SHA-256").digest(new TextEncoder().encode("reserve:" + data.identifier))), options);
886
903
  const storageOption = getStorageOption(data.identifier, options.verification?.storeIdentifier);
887
904
  const storedIdentifier = await processIdentifier(data.identifier, storageOption);
888
905
  if (secondaryStorage && !options.verification?.storeInDatabase) throw new BetterAuthError("reserveVerificationValue requires database-backed verification storage. Set verification.storeInDatabase to true for flows that reserve verification values.");
@@ -11,10 +11,10 @@ declare function toZodSchema<Fields extends Record<string, DBFieldAttribute | ne
11
11
  * If true, then any fields that have `input: false` will be removed from the schema to prevent user input.
12
12
  */
13
13
  isClientSide: IsClientSide;
14
- }): z.ZodObject<RemoveNeverProps<{ [key in keyof Fields]: FieldAttributeToSchema<Fields[key], IsClientSide> }>, z.core.$strip>;
15
- type FieldAttributeToSchema<Field extends DBFieldAttribute | Record<string, never>, isClientSide extends boolean = false> = Field extends {
16
- type: any;
17
- } ? GetInput<isClientSide, Field, GetRequired<Field, GetType<Field>>> : Record<string, never>;
14
+ }): ZodSchemaForSide<Fields, IsClientSide>;
15
+ type ZodSchemaForSide<Fields extends Record<string, DBFieldAttribute | never>, IsClientSide extends boolean> = IsClientSide extends true ? z.ZodObject<SchemaShape<Fields, true>, z.core.$strip> : z.ZodObject<SchemaShape<Fields, false>, z.core.$strip>;
16
+ type SchemaShape<Fields extends Record<string, DBFieldAttribute | never>, IsClientSide extends boolean> = RemoveNeverProps<{ [key in keyof Fields]: FieldAttributeToSchema<Fields[key], IsClientSide> }>;
17
+ type FieldAttributeToSchema<Field extends DBFieldAttribute | Record<string, never>, isClientSide extends boolean = false> = Field extends DBFieldAttribute ? GetSchemaForSide<isClientSide, Field, GetRequired<Field, GetType<Field>>> : Record<string, never>;
18
18
  type GetType<F extends DBFieldAttribute> = F extends {
19
19
  type: "string";
20
20
  } ? z.ZodString : F extends {
@@ -27,9 +27,11 @@ type GetType<F extends DBFieldAttribute> = F extends {
27
27
  type GetRequired<F extends DBFieldAttribute, Schema extends z.core.SomeType> = F extends {
28
28
  required: false;
29
29
  } ? z.ZodOptional<z.ZodNullable<Schema>> : Schema;
30
- type GetInput<isClientSide extends boolean, Field extends DBFieldAttribute, Schema extends z.core.SomeType> = Field extends {
30
+ type GetSchemaForSide<isClientSide extends boolean, Field extends DBFieldAttribute, Schema extends z.core.SomeType> = isClientSide extends true ? Field extends {
31
31
  input: false;
32
- } ? isClientSide extends true ? never : Schema : Schema;
32
+ } ? never : Schema : Field extends {
33
+ returned: false;
34
+ } ? never : Schema;
33
35
  type RemoveNeverProps<T> = { [K in keyof T as [T[K]] extends [never] ? never : K]: T[K] };
34
36
  //#endregion
35
37
  export { FieldAttributeToSchema, toZodSchema };
@@ -1,15 +1,25 @@
1
1
  import * as z from "zod";
2
2
  //#region src/db/to-zod.ts
3
+ /**
4
+ * Static lookup keeps `zod` tree-shakeable. `z[field.type]()` forces bundlers
5
+ * to retain every `zod` export, including all locales.
6
+ */
7
+ const scalarSchemas = {
8
+ string: z.string,
9
+ number: z.number,
10
+ boolean: z.boolean,
11
+ date: z.date
12
+ };
3
13
  function toZodSchema({ fields, isClientSide }) {
4
14
  const zodFields = Object.keys(fields).reduce((acc, key) => {
5
15
  const field = fields[key];
6
16
  if (!field) return acc;
7
17
  if (isClientSide && field.input === false) return acc;
8
18
  let schema;
9
- if (field.type === "json") schema = z.json ? z.json() : z.any();
19
+ if (field.type === "json") schema = z.json();
10
20
  else if (field.type === "string[]" || field.type === "number[]") schema = z.array(field.type === "string[]" ? z.string() : z.number());
11
21
  else if (Array.isArray(field.type)) schema = z.any();
12
- else schema = z[field.type]();
22
+ else schema = scalarSchemas[field.type]();
13
23
  if (field?.required === false) schema = schema.nullish();
14
24
  if (!isClientSide && field?.returned === false) return acc;
15
25
  return {
package/dist/index.mjs CHANGED
@@ -2,7 +2,6 @@ import { getBaseURL, getHost, getHostFromSource, getOrigin, getProtocol, getProt
2
2
  import { generateGenericState, parseGenericState } from "./state.mjs";
3
3
  import { generateIdTokenNonce, generateState, parseState } from "./oauth2/state.mjs";
4
4
  import { HIDE_METADATA } from "./utils/hide-metadata.mjs";
5
- import "./utils/index.mjs";
6
5
  import { APIError } from "./api/index.mjs";
7
6
  import { betterAuth } from "./auth/full.mjs";
8
7
  import { getCurrentAdapter } from "@better-auth/core/context";
package/dist/package.mjs CHANGED
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "1.7.7";
2
+ var version = "1.7.8";
3
3
  //#endregion
4
4
  export { version };
@@ -37,25 +37,18 @@ const customSession = (fn, options, pluginOptions) => {
37
37
  },
38
38
  requireHeaders: true
39
39
  }, async (ctx) => {
40
- const session = await getSession()({
40
+ const { headers, response } = await getSession()({
41
41
  ...ctx,
42
42
  method: "GET",
43
43
  asResponse: false,
44
44
  headers: ctx.headers,
45
45
  returnHeaders: true
46
- }).catch((e) => {
47
- return null;
48
46
  });
49
- if (!session?.response) return ctx.json(null);
50
- const fnResult = await fn(session.response, ctx);
51
- for (const cookieStr of session.headers.getSetCookie()) parseSetCookieHeader(cookieStr).forEach((attrs, name) => {
52
- ctx.setCookie(name, attrs.value, toCookieOptions(attrs));
53
- });
54
- session.headers.delete("set-cookie");
55
- session.headers.forEach((value, key) => {
56
- ctx.setHeader(key, value);
57
- });
58
- return ctx.json(fnResult);
47
+ const sessionPayload = response ? await fn(response, ctx) : null;
48
+ for (const cookie of headers.getSetCookie()) for (const [name, attributes] of parseSetCookieHeader(cookie)) ctx.setCookie(name, attributes.value, toCookieOptions(attributes));
49
+ headers.delete("set-cookie");
50
+ headers.forEach((value, name) => ctx.setHeader(name, value));
51
+ return ctx.json(sessionPayload);
59
52
  }) },
60
53
  $Infer: { Session: {} },
61
54
  options: pluginOptions
@@ -6,7 +6,7 @@ import { symmetricDecrypt } from "../../crypto/index.mjs";
6
6
  import { setCookieCache, setSessionCookie } from "../../cookies/index.mjs";
7
7
  import { revokeUnprovenAccountAccess } from "../../db/revoke-unproven-account-access.mjs";
8
8
  import { getSessionFromCtx, sensitiveSessionMiddleware } from "../../api/routes/session.mjs";
9
- import { assertPasswordNotTooLong, assertPasswordNotTooShort } from "../../utils/password.mjs";
9
+ import { assertPasswordNotTooLong, assertPasswordNotTooShort, resetCredentialPassword } from "../../utils/password.mjs";
10
10
  import { APIError as APIError$1 } from "../../api/index.mjs";
11
11
  import { EMAIL_OTP_ERROR_CODES } from "./error-codes.mjs";
12
12
  import { splitAtLastColon, toOTPIdentifier } from "./utils.mjs";
@@ -584,20 +584,18 @@ const resetPasswordEmailOTP = (opts) => createAuthEndpoint("/email-otp/reset-pas
584
584
  const email = ctx.body.email.toLowerCase();
585
585
  assertPasswordNotTooShort(ctx, ctx.body.password);
586
586
  assertPasswordNotTooLong(ctx, ctx.body.password);
587
- await atomicVerifyOTP(ctx, opts, toOTPIdentifier("forget-password", email), ctx.body.otp);
587
+ const identifier = toOTPIdentifier("forget-password", email);
588
+ let otpConsumed = false;
589
+ if (!await matchesUsableStoredOTP(ctx, opts, identifier, ctx.body.otp)) {
590
+ await atomicVerifyOTP(ctx, opts, identifier, ctx.body.otp);
591
+ otpConsumed = true;
592
+ }
588
593
  const user = await ctx.context.internalAdapter.findUserByEmail(email);
589
594
  if (!user) throw APIError$1.from("BAD_REQUEST", BASE_ERROR_CODES.USER_NOT_FOUND);
590
595
  const passwordHash = await ctx.context.password.hash(ctx.body.password);
591
- if (!await ctx.context.internalAdapter.findCredentialAccount(user.user.id)) await ctx.context.internalAdapter.createAccount({
592
- userId: user.user.id,
593
- providerId: "credential",
594
- accountId: user.user.id,
595
- password: passwordHash
596
- });
597
- else await ctx.context.internalAdapter.updatePassword(user.user.id, passwordHash);
596
+ await resetCredentialPassword(ctx, user.user.id, passwordHash, otpConsumed ? void 0 : () => atomicVerifyOTP(ctx, opts, identifier, ctx.body.otp));
598
597
  if (ctx.context.options.emailAndPassword?.onPasswordReset) await ctx.context.options.emailAndPassword.onPasswordReset({ user: user.user }, ctx.request);
599
598
  if (!user.user.emailVerified) await ctx.context.internalAdapter.updateUser(user.user.id, { emailVerified: true });
600
- if (ctx.context.options.emailAndPassword?.revokeSessionsOnPasswordReset) await ctx.context.internalAdapter.deleteUserSessions(user.user.id);
601
599
  return ctx.json({ success: true });
602
600
  });
603
601
  const requestEmailChangeEmailOTPBodySchema = z.object({
@@ -748,6 +746,17 @@ const changeEmailEmailOTP = (opts) => createAuthEndpoint("/email-otp/change-emai
748
746
  });
749
747
  const defaultOTPGenerator = (options) => generateRandomString(options.otpLength ?? 6, "0-9");
750
748
  /**
749
+ * Reports whether the provided OTP matches a stored, unexpired record with
750
+ * attempts left, without consuming it or counting an attempt.
751
+ */
752
+ async function matchesUsableStoredOTP(ctx, opts, identifier, providedOTP) {
753
+ const existing = await ctx.context.internalAdapter.findVerificationValue(identifier);
754
+ if (!existing || existing.expiresAt < /* @__PURE__ */ new Date()) return false;
755
+ const [otpValue, attempts] = splitAtLastColon(existing.value);
756
+ if (parseInt(attempts || "0") >= (opts?.allowedAttempts || 3)) return false;
757
+ return verifyStoredOTP(ctx, opts, otpValue, providedOTP);
758
+ }
759
+ /**
751
760
  * Verifies a single-use OTP with race-condition protection.
752
761
  *
753
762
  * The atomic consume is the single gate: only the first concurrent caller
@@ -9,10 +9,12 @@ import { OktaOptions, okta } from "./providers/okta.mjs";
9
9
  import { PatreonOptions, patreon } from "./providers/patreon.mjs";
10
10
  import { SlackOptions, slack } from "./providers/slack.mjs";
11
11
  import { YandexOptions, yandex } from "./providers/yandex.mjs";
12
+ import * as _$_better_auth_core0 from "@better-auth/core";
12
13
  import { AuthContext } from "@better-auth/core";
13
14
  import * as _$_better_auth_core_oauth20 from "@better-auth/core/oauth2";
14
15
  import { OAuthProvider } from "@better-auth/core/oauth2";
15
16
  import * as _$_better_auth_core_utils_error_codes0 from "@better-auth/core/utils/error-codes";
17
+ import * as _$better_call0 from "better-call";
16
18
 
17
19
  //#region src/plugins/generic-oauth/index.d.ts
18
20
  declare module "@better-auth/core" {
@@ -27,6 +29,10 @@ declare module "@better-auth/core" {
27
29
  * Extracts common fields from GenericOAuthConfig for provider helpers.
28
30
  */
29
31
  type BaseOAuthProviderOptions = Pick<GenericOAuthConfig, "clientId" | "clientSecret" | "tokenEndpointAuth" | "scopes" | "redirectURI" | "endSessionEndpoint" | "postLogoutRedirectURI" | "disableProviderLogout" | "pkce" | "disableImplicitSignUp" | "disableSignUp" | "overrideUserInfo">;
32
+ type PendingDiscovery = Map<string, {
33
+ config: GenericOAuthConfig;
34
+ attempt?: Promise<void> | undefined;
35
+ }>;
30
36
  /**
31
37
  * A generic OAuth plugin that registers any OAuth/OIDC provider
32
38
  * as a first-class social provider.
@@ -40,8 +46,15 @@ declare const genericOAuth: <const ID extends string>(options: GenericOAuthOptio
40
46
  init: (ctx: AuthContext) => Promise<{
41
47
  context: {
42
48
  socialProviders: OAuthProvider<object, Partial<_$_better_auth_core_oauth20.ProviderOptions<object>>>[];
49
+ genericOAuthPendingDiscovery: PendingDiscovery;
43
50
  };
44
51
  }>;
52
+ hooks: {
53
+ before: {
54
+ matcher: (context: _$_better_auth_core0.HookEndpointContext) => boolean;
55
+ handler: _$better_call0.Middleware<_$better_call0.MiddlewareOptions, (inputContext: _$better_call0.MiddlewareInputContext<_$better_call0.MiddlewareOptions>) => Promise<void>>;
56
+ }[];
57
+ };
45
58
  options: GenericOAuthOptions<ID>;
46
59
  $ERROR_CODES: {
47
60
  INVALID_OAUTH_CONFIGURATION: _$_better_auth_core_utils_error_codes0.RawError<"INVALID_OAUTH_CONFIGURATION">;