better-auth 1.7.15 → 1.7.17

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 (68) hide show
  1. package/dist/api/index.d.mts +2 -1
  2. package/dist/api/index.mjs +3 -2
  3. package/dist/api/routes/account.mjs +14 -6
  4. package/dist/api/routes/email-verification.mjs +29 -37
  5. package/dist/api/routes/sign-in.mjs +2 -2
  6. package/dist/api/routes/sign-up.mjs +1 -1
  7. package/dist/api/routes/update-user.mjs +10 -4
  8. package/dist/api/state/session-source.d.mts +10 -0
  9. package/dist/api/state/session-source.mjs +28 -0
  10. package/dist/auth/trusted-origins.mjs +33 -8
  11. package/dist/client/index.d.mts +2 -1
  12. package/dist/cookies/index.mjs +1 -0
  13. package/dist/db/internal-adapter.mjs +28 -10
  14. package/dist/index.d.mts +2 -2
  15. package/dist/index.mjs +2 -2
  16. package/dist/oauth2/link-account.d.mts +3 -5
  17. package/dist/oauth2/link-account.mjs +33 -3
  18. package/dist/oauth2/state.d.mts +6 -5
  19. package/dist/package.mjs +1 -1
  20. package/dist/plugins/admin/access/statement.d.mts +10 -10
  21. package/dist/plugins/admin/access/statement.mjs +2 -0
  22. package/dist/plugins/admin/admin.d.mts +4 -3
  23. package/dist/plugins/admin/client.d.mts +5 -4
  24. package/dist/plugins/admin/error-codes.d.mts +1 -0
  25. package/dist/plugins/admin/error-codes.mjs +1 -0
  26. package/dist/plugins/admin/routes.mjs +8 -0
  27. package/dist/plugins/anonymous/index.mjs +2 -1
  28. package/dist/plugins/device-authorization/routes.mjs +4 -4
  29. package/dist/plugins/email-otp/routes.mjs +33 -50
  30. package/dist/plugins/generic-oauth/index.mjs +54 -30
  31. package/dist/plugins/index.d.mts +2 -1
  32. package/dist/plugins/index.mjs +2 -1
  33. package/dist/plugins/oauth-proxy/index.mjs +1 -1
  34. package/dist/plugins/one-tap/index.mjs +1 -1
  35. package/dist/plugins/organization/adapter.mjs +1 -0
  36. package/dist/plugins/organization/client.d.mts +1 -0
  37. package/dist/plugins/organization/error-codes.d.mts +1 -0
  38. package/dist/plugins/organization/error-codes.mjs +1 -0
  39. package/dist/plugins/organization/has-permission.d.mts +2 -3
  40. package/dist/plugins/organization/has-permission.mjs +62 -23
  41. package/dist/plugins/organization/index.d.mts +2 -1
  42. package/dist/plugins/organization/index.mjs +2 -1
  43. package/dist/plugins/organization/organization.d.mts +2 -2
  44. package/dist/plugins/organization/organization.mjs +1 -3
  45. package/dist/plugins/organization/permission.mjs +3 -3
  46. package/dist/plugins/organization/roles.d.mts +8 -0
  47. package/dist/plugins/organization/roles.mjs +26 -0
  48. package/dist/plugins/organization/routes/crud-access-control.mjs +2 -1
  49. package/dist/plugins/organization/routes/crud-invites.mjs +22 -11
  50. package/dist/plugins/organization/routes/crud-members.mjs +10 -14
  51. package/dist/plugins/organization/routes/crud-org.mjs +12 -3
  52. package/dist/plugins/phone-number/client.d.mts +2 -0
  53. package/dist/plugins/phone-number/error-codes.d.mts +2 -0
  54. package/dist/plugins/phone-number/error-codes.mjs +2 -0
  55. package/dist/plugins/phone-number/index.d.mts +25 -1
  56. package/dist/plugins/phone-number/index.mjs +22 -6
  57. package/dist/plugins/phone-number/routes.mjs +34 -35
  58. package/dist/plugins/two-factor/backup-codes/index.d.mts +2 -2
  59. package/dist/plugins/two-factor/index.d.mts +15 -8
  60. package/dist/plugins/two-factor/index.mjs +74 -26
  61. package/dist/plugins/two-factor/otp/index.d.mts +2 -2
  62. package/dist/plugins/two-factor/totp/index.d.mts +2 -2
  63. package/dist/plugins/two-factor/verify-two-factor.mjs +4 -2
  64. package/dist/plugins/username/index.mjs +1 -1
  65. package/dist/state.d.mts +6 -0
  66. package/dist/state.mjs +5 -2
  67. package/dist/utils/index.d.mts +2 -2
  68. package/package.json +11 -11
@@ -2,10 +2,10 @@ import { toZodSchema } from "../../../db/to-zod.mjs";
2
2
  import { getSessionFromCtx, sessionMiddleware } from "../../../api/routes/session.mjs";
3
3
  import { defaultRoles } from "../access/statement.mjs";
4
4
  import { ORGANIZATION_ERROR_CODES } from "../error-codes.mjs";
5
+ import { parseRoleList, parseRoles } from "../roles.mjs";
5
6
  import { getOrgAdapter } from "../adapter.mjs";
6
7
  import { orgMiddleware, orgSessionMiddleware } from "../call.mjs";
7
8
  import { hasPermission } from "../has-permission.mjs";
8
- import { parseRoles } from "../organization.mjs";
9
9
  import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
10
10
  import { whereOperators } from "@better-auth/core/db/adapter";
11
11
  import { createAuthEndpoint } from "@better-auth/core/api";
@@ -190,14 +190,12 @@ const removeMember = (options) => createAuthEndpoint("/organization/remove-membe
190
190
  }
191
191
  }
192
192
  if (!toBeRemovedMember) throw APIError.from("BAD_REQUEST", ORGANIZATION_ERROR_CODES.MEMBER_NOT_FOUND);
193
- const roles = toBeRemovedMember.role.split(",");
193
+ const roles = parseRoleList(toBeRemovedMember.role);
194
194
  const creatorRole = ctx.context.orgOptions?.creatorRole || "owner";
195
195
  if (roles.includes(creatorRole)) {
196
- if (!member.role.split(",").map((r) => r.trim()).includes(creatorRole)) throw APIError.from("BAD_REQUEST", ORGANIZATION_ERROR_CODES.YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNER);
196
+ if (!parseRoleList(member.role).includes(creatorRole)) throw APIError.from("BAD_REQUEST", ORGANIZATION_ERROR_CODES.YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNER);
197
197
  const { members } = await adapter.listMembers({ organizationId });
198
- if (members.filter((member) => {
199
- return member.role.split(",").includes(creatorRole);
200
- }).length <= 1) throw APIError.from("BAD_REQUEST", ORGANIZATION_ERROR_CODES.YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNER);
198
+ if (members.filter((member) => parseRoleList(member.role).includes(creatorRole)).length <= 1) throw APIError.from("BAD_REQUEST", ORGANIZATION_ERROR_CODES.YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNER);
201
199
  }
202
200
  if (!await hasPermission({
203
201
  role: member.role,
@@ -273,7 +271,7 @@ const updateMemberRole = (option) => createAuthEndpoint("/organization/update-me
273
271
  const organizationId = ctx.body.organizationId || session.session.activeOrganizationId;
274
272
  if (!organizationId) throw APIError.from("BAD_REQUEST", ORGANIZATION_ERROR_CODES.NO_ACTIVE_ORGANIZATION);
275
273
  const adapter = getOrgAdapter(ctx.context, ctx.context.orgOptions);
276
- const roleToSet = (Array.isArray(ctx.body.role) ? ctx.body.role : [ctx.body.role]).flatMap((role) => role.split(",")).map((role) => role.trim()).filter(Boolean);
274
+ const roleToSet = parseRoleList(ctx.body.role);
277
275
  if (roleToSet.length === 0) throw APIError.fromStatus("BAD_REQUEST");
278
276
  const member = await adapter.findMemberByOrgId({
279
277
  userId: session.user.id,
@@ -284,8 +282,8 @@ const updateMemberRole = (option) => createAuthEndpoint("/organization/update-me
284
282
  if (!toBeUpdatedMember) throw APIError.from("BAD_REQUEST", ORGANIZATION_ERROR_CODES.MEMBER_NOT_FOUND);
285
283
  if (!(toBeUpdatedMember.organizationId === organizationId)) throw APIError.from("FORBIDDEN", ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_MEMBER);
286
284
  const creatorRole = ctx.context.orgOptions?.creatorRole || "owner";
287
- const updatingMemberRoles = member.role.split(",");
288
- const isUpdatingCreator = toBeUpdatedMember.role.split(",").includes(creatorRole);
285
+ const updatingMemberRoles = parseRoleList(member.role);
286
+ const isUpdatingCreator = parseRoleList(toBeUpdatedMember.role).includes(creatorRole);
289
287
  const updaterIsCreator = updatingMemberRoles.includes(creatorRole);
290
288
  const isSettingCreatorRole = roleToSet.includes(creatorRole);
291
289
  const memberIsUpdatingThemselves = member.id === toBeUpdatedMember.id;
@@ -297,9 +295,7 @@ const updateMemberRole = (option) => createAuthEndpoint("/organization/update-me
297
295
  field: "organizationId",
298
296
  value: organizationId
299
297
  }]
300
- })).filter((member) => {
301
- return member.role.split(",").includes(creatorRole);
302
- }).length <= 1 && !isSettingCreatorRole) throw APIError.from("BAD_REQUEST", ORGANIZATION_ERROR_CODES.YOU_CANNOT_LEAVE_THE_ORGANIZATION_WITHOUT_AN_OWNER);
298
+ })).filter((member) => parseRoleList(member.role).includes(creatorRole)).length <= 1 && !isSettingCreatorRole) throw APIError.from("BAD_REQUEST", ORGANIZATION_ERROR_CODES.YOU_CANNOT_LEAVE_THE_ORGANIZATION_WITHOUT_AN_OWNER);
303
299
  }
304
300
  if (!await hasPermission({
305
301
  role: member.role,
@@ -419,14 +415,14 @@ const leaveOrganization = (options) => createAuthEndpoint("/organization/leave",
419
415
  });
420
416
  if (!member) throw APIError.from("BAD_REQUEST", ORGANIZATION_ERROR_CODES.MEMBER_NOT_FOUND);
421
417
  const creatorRole = ctx.context.orgOptions?.creatorRole || "owner";
422
- if (member.role.split(",").includes(creatorRole)) {
418
+ if (parseRoleList(member.role).includes(creatorRole)) {
423
419
  if ((await ctx.context.adapter.findMany({
424
420
  model: "member",
425
421
  where: [{
426
422
  field: "organizationId",
427
423
  value: ctx.body.organizationId
428
424
  }]
429
- })).filter((member) => member.role.split(",").includes(creatorRole)).length <= 1) throw APIError.from("BAD_REQUEST", ORGANIZATION_ERROR_CODES.YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNER);
425
+ })).filter((member) => parseRoleList(member.role).includes(creatorRole)).length <= 1) throw APIError.from("BAD_REQUEST", ORGANIZATION_ERROR_CODES.YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNER);
430
426
  }
431
427
  await adapter.deleteMember({
432
428
  memberId: member.id,
@@ -4,7 +4,7 @@ import { getSessionFromCtx, requestOnlySessionMiddleware } from "../../../api/ro
4
4
  import { ORGANIZATION_ERROR_CODES } from "../error-codes.mjs";
5
5
  import { getOrgAdapter } from "../adapter.mjs";
6
6
  import { orgMiddleware, orgSessionMiddleware } from "../call.mjs";
7
- import { hasPermission } from "../has-permission.mjs";
7
+ import { canViewInvitations, hasPermission, withoutCreatorRoleInvitations } from "../has-permission.mjs";
8
8
  import { APIError } from "@better-auth/core/error";
9
9
  import { createAuthEndpoint } from "@better-auth/core/api";
10
10
  import * as z from "zod";
@@ -362,13 +362,22 @@ const getFullOrganization = (options) => createAuthEndpoint("/organization/get-f
362
362
  membersLimit: ctx.query?.membersLimit
363
363
  });
364
364
  if (!organization) throw APIError.from("BAD_REQUEST", ORGANIZATION_ERROR_CODES.ORGANIZATION_NOT_FOUND);
365
- if (!await adapter.checkMembership({
365
+ const isMember = await adapter.checkMembership({
366
366
  userId: session.user.id,
367
367
  organizationId: organization.id
368
- })) {
368
+ });
369
+ if (!isMember) {
369
370
  await adapter.setActiveOrganization(session.session.token, null, ctx);
370
371
  throw APIError.from("FORBIDDEN", ORGANIZATION_ERROR_CODES.USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION);
371
372
  }
373
+ organization.invitations = organization.invitations.length > 0 && await canViewInvitations({
374
+ role: isMember.role,
375
+ options: ctx.context.orgOptions,
376
+ organizationId: organization.id
377
+ }, ctx) ? withoutCreatorRoleInvitations(organization.invitations, {
378
+ role: isMember.role,
379
+ options: ctx.context.orgOptions
380
+ }) : [];
372
381
  return ctx.json(organization);
373
382
  });
374
383
  const setActiveOrganizationBodySchema = z.object({
@@ -21,7 +21,9 @@ export declare const phoneNumberClient: () => {
21
21
  UNEXPECTED_ERROR: import("@better-auth/core/utils/error-codes").RawError<"UNEXPECTED_ERROR">;
22
22
  OTP_NOT_FOUND: import("@better-auth/core/utils/error-codes").RawError<"OTP_NOT_FOUND">;
23
23
  PHONE_NUMBER_NOT_VERIFIED: import("@better-auth/core/utils/error-codes").RawError<"PHONE_NUMBER_NOT_VERIFIED">;
24
+ PHONE_NUMBER_PENDING_VERIFICATION: import("@better-auth/core/utils/error-codes").RawError<"PHONE_NUMBER_PENDING_VERIFICATION">;
24
25
  PHONE_NUMBER_CANNOT_BE_UPDATED: import("@better-auth/core/utils/error-codes").RawError<"PHONE_NUMBER_CANNOT_BE_UPDATED">;
26
+ PHONE_NUMBER_CANNOT_BE_SET: import("@better-auth/core/utils/error-codes").RawError<"PHONE_NUMBER_CANNOT_BE_SET">;
25
27
  SEND_OTP_NOT_IMPLEMENTED: import("@better-auth/core/utils/error-codes").RawError<"SEND_OTP_NOT_IMPLEMENTED">;
26
28
  };
27
29
  };
@@ -10,7 +10,9 @@ export declare const PHONE_NUMBER_ERROR_CODES: {
10
10
  UNEXPECTED_ERROR: import("@better-auth/core/utils/error-codes").RawError<"UNEXPECTED_ERROR">;
11
11
  OTP_NOT_FOUND: import("@better-auth/core/utils/error-codes").RawError<"OTP_NOT_FOUND">;
12
12
  PHONE_NUMBER_NOT_VERIFIED: import("@better-auth/core/utils/error-codes").RawError<"PHONE_NUMBER_NOT_VERIFIED">;
13
+ PHONE_NUMBER_PENDING_VERIFICATION: import("@better-auth/core/utils/error-codes").RawError<"PHONE_NUMBER_PENDING_VERIFICATION">;
13
14
  PHONE_NUMBER_CANNOT_BE_UPDATED: import("@better-auth/core/utils/error-codes").RawError<"PHONE_NUMBER_CANNOT_BE_UPDATED">;
15
+ PHONE_NUMBER_CANNOT_BE_SET: import("@better-auth/core/utils/error-codes").RawError<"PHONE_NUMBER_CANNOT_BE_SET">;
14
16
  SEND_OTP_NOT_IMPLEMENTED: import("@better-auth/core/utils/error-codes").RawError<"SEND_OTP_NOT_IMPLEMENTED">;
15
17
  };
16
18
  //#endregion
@@ -10,7 +10,9 @@ const PHONE_NUMBER_ERROR_CODES = defineErrorCodes({
10
10
  OTP_EXPIRED: "OTP expired",
11
11
  INVALID_OTP: "Invalid OTP",
12
12
  PHONE_NUMBER_NOT_VERIFIED: "Phone number not verified",
13
+ PHONE_NUMBER_PENDING_VERIFICATION: "Phone number requires account ownership proof",
13
14
  PHONE_NUMBER_CANNOT_BE_UPDATED: "Phone number cannot be updated",
15
+ PHONE_NUMBER_CANNOT_BE_SET: "Phone number can only be set by verifying it",
14
16
  SEND_OTP_NOT_IMPLEMENTED: "sendOTP not implemented",
15
17
  TOO_MANY_ATTEMPTS: "Too many attempts"
16
18
  });
@@ -14,6 +14,28 @@ export declare const phoneNumber: (options?: PhoneNumberOptions | undefined) =>
14
14
  options: {
15
15
  databaseHooks: {
16
16
  user: {
17
+ create: {
18
+ before(data: {
19
+ id: string;
20
+ createdAt: Date;
21
+ updatedAt: Date;
22
+ email: string;
23
+ emailVerified: boolean;
24
+ name: string;
25
+ image?: string | null | undefined;
26
+ } & Record<string, unknown>, ctx: import("@better-auth/core").GenericEndpointContext | null): Promise<{
27
+ data: {
28
+ [x: string]: unknown;
29
+ id: string;
30
+ createdAt: Date;
31
+ updatedAt: Date;
32
+ email: string;
33
+ emailVerified: boolean;
34
+ name: string;
35
+ image?: string | null | undefined;
36
+ };
37
+ } | undefined>;
38
+ };
17
39
  update: {
18
40
  before(data: Partial<{
19
41
  id: string;
@@ -23,7 +45,7 @@ export declare const phoneNumber: (options?: PhoneNumberOptions | undefined) =>
23
45
  emailVerified: boolean;
24
46
  name: string;
25
47
  image?: string | null | undefined;
26
- }> & Record<string, unknown>): Promise<{
48
+ }> & Record<string, unknown>, ctx: import("@better-auth/core").GenericEndpointContext | null): Promise<{
27
49
  data: {
28
50
  [x: string]: unknown;
29
51
  id?: string | undefined;
@@ -346,7 +368,9 @@ export declare const phoneNumber: (options?: PhoneNumberOptions | undefined) =>
346
368
  UNEXPECTED_ERROR: import("@better-auth/core/utils/error-codes").RawError<"UNEXPECTED_ERROR">;
347
369
  OTP_NOT_FOUND: import("@better-auth/core/utils/error-codes").RawError<"OTP_NOT_FOUND">;
348
370
  PHONE_NUMBER_NOT_VERIFIED: import("@better-auth/core/utils/error-codes").RawError<"PHONE_NUMBER_NOT_VERIFIED">;
371
+ PHONE_NUMBER_PENDING_VERIFICATION: import("@better-auth/core/utils/error-codes").RawError<"PHONE_NUMBER_PENDING_VERIFICATION">;
349
372
  PHONE_NUMBER_CANNOT_BE_UPDATED: import("@better-auth/core/utils/error-codes").RawError<"PHONE_NUMBER_CANNOT_BE_UPDATED">;
373
+ PHONE_NUMBER_CANNOT_BE_SET: import("@better-auth/core/utils/error-codes").RawError<"PHONE_NUMBER_CANNOT_BE_SET">;
350
374
  SEND_OTP_NOT_IMPLEMENTED: import("@better-auth/core/utils/error-codes").RawError<"SEND_OTP_NOT_IMPLEMENTED">;
351
375
  };
352
376
  };
@@ -20,12 +20,28 @@ const phoneNumber = (options) => {
20
20
  id: "phone-number",
21
21
  version: PACKAGE_VERSION,
22
22
  init() {
23
- return { options: { databaseHooks: { user: { update: { async before(data) {
24
- if (opts.phoneNumber in data && data[opts.phoneNumber] === null) return { data: {
25
- ...data,
26
- [opts.phoneNumberVerified]: false
27
- } };
28
- } } } } } };
23
+ return { options: { databaseHooks: { user: {
24
+ create: { async before(data, ctx) {
25
+ if (data[opts.phoneNumber] == null || data[opts.phoneNumberVerified] === true) return;
26
+ if (ctx?.body?.[opts.phoneNumber]) throw APIError.from("BAD_REQUEST", PHONE_NUMBER_ERROR_CODES.PHONE_NUMBER_CANNOT_BE_SET);
27
+ return { data: {
28
+ ...data,
29
+ [opts.phoneNumber]: null,
30
+ [opts.phoneNumberVerified]: false
31
+ } };
32
+ } },
33
+ update: { async before(data, ctx) {
34
+ if (opts.phoneNumber in data && data[opts.phoneNumber] === null) return { data: {
35
+ ...data,
36
+ [opts.phoneNumberVerified]: false
37
+ } };
38
+ if (!ctx || data[opts.phoneNumber] == null || data[opts.phoneNumberVerified] === true || ctx.path === "/admin/update-user") return;
39
+ return { data: {
40
+ ...data,
41
+ [opts.phoneNumber]: void 0
42
+ } };
43
+ } }
44
+ } } } };
29
45
  },
30
46
  hooks: { before: [{
31
47
  matcher: (ctx) => ctx.path === "/update-user" && "phoneNumber" in ctx.body && ctx.body.phoneNumber !== null,
@@ -2,9 +2,10 @@ import { parseUserInput, parseUserOutput } from "../../db/schema.mjs";
2
2
  import { getDate } from "../../utils/date.mjs";
3
3
  import { generateRandomString } from "../../crypto/random.mjs";
4
4
  import { setSessionCookie } from "../../cookies/index.mjs";
5
- import { getSessionFromCtx } from "../../api/routes/session.mjs";
5
+ import { getAuthoritativeSessionFromCtx } from "../../api/routes/session.mjs";
6
6
  import { HIDE_METADATA } from "../../utils/hide-metadata.mjs";
7
7
  import { assertPasswordNotTooLong, assertPasswordNotTooShort, resetCredentialPassword } from "../../utils/password.mjs";
8
+ import { isSessionOutsideStore } from "../../api/state/session-source.mjs";
8
9
  import { PHONE_NUMBER_ERROR_CODES } from "./error-codes.mjs";
9
10
  import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
10
11
  import { createAuthEndpoint } from "@better-auth/core/api";
@@ -69,7 +70,7 @@ const signInPhoneNumber = (opts) => createAuthEndpoint("/sign-in/phone-number",
69
70
  const otp = generateOTP(opts.otpLength);
70
71
  await ctx.context.internalAdapter.createVerificationValue({
71
72
  value: otp,
72
- identifier: phoneNumber,
73
+ identifier: phoneNumberOTPIdentifier(phoneNumber),
73
74
  expiresAt: getDate(opts.expiresIn, "sec")
74
75
  });
75
76
  if (opts.sendOTP) await ctx.context.runInBackgroundOrAwait(opts.sendOTP({
@@ -155,7 +156,7 @@ const sendPhoneNumberOTP = (opts) => createAuthEndpoint("/phone-number/send-otp"
155
156
  const code = generateOTP(opts.otpLength);
156
157
  await ctx.context.internalAdapter.createVerificationValue({
157
158
  value: `${code}:0`,
158
- identifier: ctx.body.phoneNumber,
159
+ identifier: phoneNumberOTPIdentifier(ctx.body.phoneNumber),
159
160
  expiresAt: getDate(opts.expiresIn, "sec")
160
161
  });
161
162
  const sendOTPResult = opts.sendOTP({
@@ -174,11 +175,12 @@ const sendPhoneNumberOTP = (opts) => createAuthEndpoint("/phone-number/send-otp"
174
175
  });
175
176
  async function verifyAndConsumePhoneNumberOTP(ctx, opts, body) {
176
177
  const { phoneNumber, code } = body;
178
+ const identifier = phoneNumberOTPIdentifier(phoneNumber);
177
179
  if (opts.verifyOTP) {
178
- await verifyWithCustomVerifier(ctx, opts.verifyOTP, phoneNumber, code, phoneNumber);
180
+ await verifyWithCustomVerifier(ctx, opts.verifyOTP, phoneNumber, code, identifier);
179
181
  return;
180
182
  }
181
- await verifyPhoneNumberOTP(ctx, opts, phoneNumber, code);
183
+ await verifyPhoneNumberOTP(ctx, opts, identifier, code);
182
184
  }
183
185
  /** Checks the code with `verifyOTP`, then drops the code stored under `identifier`. */
184
186
  async function verifyWithCustomVerifier(ctx, verifyOTP, phoneNumber, code, identifier) {
@@ -330,7 +332,8 @@ const verifyPhoneNumber = (opts) => createAuthEndpoint("/phone-number/verify", {
330
332
  }, async (ctx) => {
331
333
  await verifyAndConsumePhoneNumberOTP(ctx, opts, ctx.body);
332
334
  if (ctx.body.updatePhoneNumber) {
333
- const session = await getSessionFromCtx(ctx);
335
+ const requestSession = ctx.context.session;
336
+ const session = requestSession && await isSessionOutsideStore(requestSession.session.token) ? requestSession : await getAuthoritativeSessionFromCtx(ctx);
334
337
  if (!session) throw APIError.from("UNAUTHORIZED", BASE_ERROR_CODES.USER_NOT_FOUND);
335
338
  if ((await ctx.context.adapter.findMany({
336
339
  model: "user",
@@ -429,10 +432,10 @@ const requestPasswordResetPhoneNumber = (opts) => createAuthEndpoint("/phone-num
429
432
  const code = generateOTP(opts.otpLength);
430
433
  await ctx.context.internalAdapter.createVerificationValue({
431
434
  value: `${code}:0`,
432
- identifier: `${ctx.body.phoneNumber}-request-password-reset`,
435
+ identifier: phoneNumberResetIdentifier(ctx.body.phoneNumber),
433
436
  expiresAt: getDate(opts.expiresIn, "sec")
434
437
  });
435
- if (!user) return ctx.json({ status: true });
438
+ if (!user || opts.requireVerification && !user.phoneNumberVerified) return ctx.json({ status: true });
436
439
  if (opts.sendPasswordResetOTP) await ctx.context.runInBackgroundOrAwait(opts.sendPasswordResetOTP({
437
440
  phoneNumber: ctx.body.phoneNumber,
438
441
  code
@@ -463,17 +466,11 @@ const resetPasswordPhoneNumber = (opts) => createAuthEndpoint("/phone-number/res
463
466
  } }
464
467
  } }
465
468
  }, async (ctx) => {
466
- const phoneResetIdentifier = `${ctx.body.phoneNumber}-request-password-reset`;
467
- let otpConsumed = false;
468
- if (opts.verifyOTP) {
469
- assertPasswordNotTooShort(ctx, ctx.body.newPassword);
470
- assertPasswordNotTooLong(ctx, ctx.body.newPassword);
471
- await verifyWithCustomVerifier(ctx, opts.verifyOTP, ctx.body.phoneNumber, ctx.body.otp, phoneResetIdentifier);
472
- otpConsumed = true;
473
- } else if (!await matchesUsableStoredPhoneNumberOTP(ctx, opts, phoneResetIdentifier, ctx.body.otp)) {
474
- await verifyPhoneNumberOTP(ctx, opts, phoneResetIdentifier, ctx.body.otp);
475
- otpConsumed = true;
476
- }
469
+ const phoneResetIdentifier = phoneNumberResetIdentifier(ctx.body.phoneNumber);
470
+ assertPasswordNotTooShort(ctx, ctx.body.newPassword);
471
+ assertPasswordNotTooLong(ctx, ctx.body.newPassword);
472
+ if (opts.verifyOTP) await verifyWithCustomVerifier(ctx, opts.verifyOTP, ctx.body.phoneNumber, ctx.body.otp, phoneResetIdentifier);
473
+ else await verifyPhoneNumberOTP(ctx, opts, phoneResetIdentifier, ctx.body.otp, "restore");
477
474
  const user = await ctx.context.adapter.findOne({
478
475
  model: "user",
479
476
  where: [{
@@ -482,10 +479,9 @@ const resetPasswordPhoneNumber = (opts) => createAuthEndpoint("/phone-number/res
482
479
  }]
483
480
  });
484
481
  if (!user) throw APIError.from("BAD_REQUEST", PHONE_NUMBER_ERROR_CODES.UNEXPECTED_ERROR);
485
- assertPasswordNotTooShort(ctx, ctx.body.newPassword);
486
- assertPasswordNotTooLong(ctx, ctx.body.newPassword);
482
+ if (opts.requireVerification && !user.phoneNumberVerified) throw APIError.from("BAD_REQUEST", PHONE_NUMBER_ERROR_CODES.PHONE_NUMBER_PENDING_VERIFICATION);
487
483
  const hashedPassword = await ctx.context.password.hash(ctx.body.newPassword);
488
- await resetCredentialPassword(ctx, user.id, hashedPassword, otpConsumed ? void 0 : () => verifyPhoneNumberOTP(ctx, opts, phoneResetIdentifier, ctx.body.otp));
484
+ await resetCredentialPassword(ctx, user.id, hashedPassword, opts.verifyOTP ? void 0 : () => verifyPhoneNumberOTP(ctx, opts, phoneResetIdentifier, ctx.body.otp));
489
485
  if (ctx.context.options.emailAndPassword?.onPasswordReset) await ctx.context.options.emailAndPassword.onPasswordReset({ user }, ctx.request);
490
486
  return ctx.json({ status: true });
491
487
  });
@@ -497,9 +493,10 @@ const resetPasswordPhoneNumber = (opts) => createAuthEndpoint("/phone-number/res
497
493
  * never satisfy two simultaneous verifications. On a wrong code that is still
498
494
  * within the attempt budget, the row is recreated with the same value and
499
495
  * expiry and an incremented attempt counter. Once the budget is exhausted the
500
- * row is not recreated.
496
+ * row is not recreated. With `"restore"`, a correct code is written back
497
+ * unchanged so a follow-up call can spend it.
501
498
  */
502
- async function verifyPhoneNumberOTP(ctx, opts, identifier, providedCode) {
499
+ async function verifyPhoneNumberOTP(ctx, opts, identifier, providedCode, successDisposition = "consume") {
503
500
  const existing = await ctx.context.internalAdapter.findVerificationValue(identifier);
504
501
  if (!existing) throw APIError.from("BAD_REQUEST", PHONE_NUMBER_ERROR_CODES.OTP_NOT_FOUND);
505
502
  if (existing.expiresAt < /* @__PURE__ */ new Date()) {
@@ -524,22 +521,24 @@ async function verifyPhoneNumberOTP(ctx, opts, identifier, providedCode) {
524
521
  });
525
522
  throw APIError.from("BAD_REQUEST", PHONE_NUMBER_ERROR_CODES.INVALID_OTP);
526
523
  }
527
- }
528
- /**
529
- * Reports whether the provided code matches a stored, unexpired record with
530
- * attempts left, without consuming it or counting an attempt.
531
- */
532
- async function matchesUsableStoredPhoneNumberOTP(ctx, opts, identifier, providedCode) {
533
- const existing = await ctx.context.internalAdapter.findVerificationValue(identifier);
534
- if (!existing || existing.expiresAt < /* @__PURE__ */ new Date()) return false;
535
- const [otpValue, rawAttempts] = existing.value.split(":");
536
- if (parseVerificationAttempts(rawAttempts) >= (opts?.allowedAttempts ?? 3)) return false;
537
- return otpValue === providedCode;
524
+ if (successDisposition === "consume") return;
525
+ if (await ctx.context.internalAdapter.findVerificationValue(identifier)) throw APIError.from("BAD_REQUEST", PHONE_NUMBER_ERROR_CODES.INVALID_OTP);
526
+ if (!await ctx.context.internalAdapter.createVerificationValue({
527
+ value: consumed.value,
528
+ identifier,
529
+ expiresAt: consumed.expiresAt
530
+ })) throw APIError.fromStatus("INTERNAL_SERVER_ERROR", { message: "Unable to complete OTP verification" });
538
531
  }
539
532
  function parseVerificationAttempts(value) {
540
533
  const attempts = Number(value ?? 0);
541
534
  return Number.isSafeInteger(attempts) && attempts > 0 ? attempts : 0;
542
535
  }
536
+ function phoneNumberOTPIdentifier(phoneNumber) {
537
+ return `phone-number-otp-${phoneNumber}`;
538
+ }
539
+ function phoneNumberResetIdentifier(phoneNumber) {
540
+ return `phone-number-reset-${phoneNumber}`;
541
+ }
543
542
  function generateOTP(size) {
544
543
  return generateRandomString(size, "0-9");
545
544
  }
@@ -170,7 +170,7 @@ export declare const backupCode2fa: (opts: BackupCodeOptions) => {
170
170
  };
171
171
  }, {
172
172
  token: string | undefined;
173
- user: (Record<string, any> & {
173
+ user: ({
174
174
  id: string;
175
175
  createdAt: Date;
176
176
  updatedAt: Date;
@@ -178,7 +178,7 @@ export declare const backupCode2fa: (opts: BackupCodeOptions) => {
178
178
  emailVerified: boolean;
179
179
  name: string;
180
180
  image?: string | null | undefined;
181
- }) | UserWithTwoFactor;
181
+ } & Record<string, any>) | UserWithTwoFactor;
182
182
  }>;
183
183
  /**
184
184
  * ### Endpoint
@@ -4,6 +4,7 @@ import { TOTPOptions, totp2fa } from "./totp/index.mjs";
4
4
  import { TwoFactorOptions, TwoFactorProvider, TwoFactorTable, UserWithTwoFactor } from "./types.mjs";
5
5
  import { TWO_FACTOR_ERROR_CODES } from "./error-code.mjs";
6
6
  import { twoFactorClient } from "./client.mjs";
7
+ import { GenericEndpointContext } from "@better-auth/core";
7
8
  import * as z from "zod";
8
9
  //#region src/plugins/two-factor/index.d.ts
9
10
  declare module "@better-auth/core" {
@@ -16,6 +17,12 @@ declare module "@better-auth/core" {
16
17
  export declare const twoFactor: <O extends TwoFactorOptions>(options?: O) => {
17
18
  id: "two-factor";
18
19
  version: string;
20
+ /**
21
+ * Whether this sign-in still requires a second factor.
22
+ *
23
+ * @internal
24
+ */
25
+ shouldRequireTwoFactor: (ctx: GenericEndpointContext) => Promise<boolean>;
19
26
  endpoints: {
20
27
  /**
21
28
  * ### Endpoint
@@ -37,15 +44,15 @@ export declare const twoFactor: <O extends TwoFactorOptions>(options?: O) => {
37
44
  body: z.ZodObject<{
38
45
  password: z.ZodOptional<z.ZodString>;
39
46
  method: z.ZodDefault<z.ZodEnum<{
40
- otp: "otp";
41
47
  totp: "totp";
48
+ otp: "otp";
42
49
  }>>;
43
50
  issuer: z.ZodOptional<z.ZodString>;
44
51
  }, z.core.$strip> | z.ZodObject<{
45
52
  password: z.ZodString;
46
53
  method: z.ZodDefault<z.ZodEnum<{
47
- otp: "otp";
48
54
  totp: "totp";
55
+ otp: "otp";
49
56
  }>>;
50
57
  issuer: z.ZodOptional<z.ZodString>;
51
58
  }, z.core.$strip>;
@@ -288,7 +295,7 @@ export declare const twoFactor: <O extends TwoFactorOptions>(options?: O) => {
288
295
  };
289
296
  }, {
290
297
  token: string | undefined;
291
- user: (Record<string, any> & {
298
+ user: ({
292
299
  id: string;
293
300
  createdAt: Date;
294
301
  updatedAt: Date;
@@ -296,7 +303,7 @@ export declare const twoFactor: <O extends TwoFactorOptions>(options?: O) => {
296
303
  emailVerified: boolean;
297
304
  name: string;
298
305
  image?: string | null | undefined;
299
- }) | UserWithTwoFactor;
306
+ } & Record<string, any>) | UserWithTwoFactor;
300
307
  }>;
301
308
  generateBackupCodes: import("better-call").StrictEndpoint<"/two-factor/generate-backup-codes", {
302
309
  method: "POST";
@@ -483,7 +490,7 @@ export declare const twoFactor: <O extends TwoFactorOptions>(options?: O) => {
483
490
  user: UserWithTwoFactor;
484
491
  } | {
485
492
  token: string;
486
- user: Record<string, any> & {
493
+ user: {
487
494
  id: string;
488
495
  createdAt: Date;
489
496
  updatedAt: Date;
@@ -491,7 +498,7 @@ export declare const twoFactor: <O extends TwoFactorOptions>(options?: O) => {
491
498
  emailVerified: boolean;
492
499
  name: string;
493
500
  image?: string | null | undefined;
494
- };
501
+ } & Record<string, any>;
495
502
  }>;
496
503
  generateTOTP: import("better-call").StrictEndpoint<string, {
497
504
  method: "POST";
@@ -614,7 +621,7 @@ export declare const twoFactor: <O extends TwoFactorOptions>(options?: O) => {
614
621
  user: UserWithTwoFactor;
615
622
  } | {
616
623
  token: string;
617
- user: Record<string, any> & {
624
+ user: {
618
625
  id: string;
619
626
  createdAt: Date;
620
627
  updatedAt: Date;
@@ -622,7 +629,7 @@ export declare const twoFactor: <O extends TwoFactorOptions>(options?: O) => {
622
629
  emailVerified: boolean;
623
630
  name: string;
624
631
  image?: string | null | undefined;
625
- };
632
+ } & Record<string, any>;
626
633
  }>;
627
634
  };
628
635
  options: NoInfer<O>;
@@ -2,8 +2,9 @@ import { mergeSchema } from "../../db/schema.mjs";
2
2
  import { generateRandomString } from "../../crypto/random.mjs";
3
3
  import { symmetricEncrypt } from "../../crypto/index.mjs";
4
4
  import { deleteSessionCookie, expireCookie, setSessionCookie } from "../../cookies/index.mjs";
5
- import { sensitiveSessionMiddleware, sessionMiddleware } from "../../api/routes/session.mjs";
5
+ import { getAuthoritativeSessionFromCtx, sensitiveSessionMiddleware, sessionMiddleware } from "../../api/routes/session.mjs";
6
6
  import { shouldRequirePassword, validatePassword } from "../../utils/password.mjs";
7
+ import { isSessionOutsideStore } from "../../api/state/session-source.mjs";
7
8
  import { PACKAGE_VERSION } from "../../version.mjs";
8
9
  import { TWO_FACTOR_ERROR_CODES } from "./error-code.mjs";
9
10
  import { twoFactorClient } from "./client.mjs";
@@ -18,6 +19,39 @@ import * as z from "zod";
18
19
  import { createHMAC } from "@better-auth/utils/hmac";
19
20
  import { createOTP } from "@better-auth/utils/otp";
20
21
  //#region src/plugins/two-factor/index.ts
22
+ const signInPaths = /* @__PURE__ */ new Set([
23
+ "/sign-in/email",
24
+ "/sign-in/username",
25
+ "/sign-in/phone-number"
26
+ ]);
27
+ const signInTwoFactorStates = /* @__PURE__ */ new WeakMap();
28
+ async function readSignInTwoFactorState(ctx, trustDeviceMaxAge) {
29
+ const data = ctx.context.newSession;
30
+ if (!data?.user.twoFactorEnabled) return {
31
+ requiresTwoFactor: false,
32
+ trustDeviceCookie: null
33
+ };
34
+ const cookie = ctx.context.createAuthCookie(TRUST_DEVICE_COOKIE_NAME, { maxAge: trustDeviceMaxAge });
35
+ const trustDeviceCookie = await ctx.getSignedCookie(cookie.name, ctx.context.secret);
36
+ if (!trustDeviceCookie) return {
37
+ requiresTwoFactor: true,
38
+ trustDeviceCookie
39
+ };
40
+ const [token, trustIdentifier] = trustDeviceCookie.split("!");
41
+ if (!token || !trustIdentifier) return {
42
+ requiresTwoFactor: true,
43
+ trustDeviceCookie
44
+ };
45
+ if (token !== await createHMAC("SHA-256", "base64urlnopad").sign(ctx.context.secret, `${data.user.id}!${trustIdentifier}`)) return {
46
+ requiresTwoFactor: true,
47
+ trustDeviceCookie
48
+ };
49
+ const verificationRecord = await ctx.context.internalAdapter.findVerificationValue(trustIdentifier);
50
+ return {
51
+ requiresTwoFactor: !verificationRecord || verificationRecord.value !== data.user.id || !(verificationRecord.expiresAt > /* @__PURE__ */ new Date()),
52
+ trustDeviceCookie
53
+ };
54
+ }
21
55
  const twoFactor = (options) => {
22
56
  const opts = { twoFactorTable: "twoFactor" };
23
57
  const trustDeviceMaxAge = options?.trustDeviceMaxAge ?? 2592e3;
@@ -48,9 +82,26 @@ const twoFactor = (options) => {
48
82
  issuer: issuerField
49
83
  });
50
84
  const disableTwoFactorBodySchema = allowPasswordless ? z.object({ password: passwordSchema.optional() }) : z.object({ password: passwordSchema });
85
+ function getSignInTwoFactorState(ctx) {
86
+ const remembered = signInTwoFactorStates.get(ctx.context);
87
+ if (remembered) return remembered;
88
+ const state = readSignInTwoFactorState(ctx, trustDeviceMaxAge);
89
+ signInTwoFactorStates.set(ctx.context, state);
90
+ return state;
91
+ }
92
+ async function shouldRequireTwoFactor(ctx) {
93
+ if (!signInPaths.has(ctx.path)) return false;
94
+ return (await getSignInTwoFactorState(ctx)).requiresTwoFactor;
95
+ }
51
96
  return {
52
97
  id: "two-factor",
53
98
  version: PACKAGE_VERSION,
99
+ /**
100
+ * Whether this sign-in still requires a second factor.
101
+ *
102
+ * @internal
103
+ */
104
+ shouldRequireTwoFactor,
54
105
  endpoints: {
55
106
  ...totp.endpoints,
56
107
  ...otp.endpoints,
@@ -102,7 +153,12 @@ const twoFactor = (options) => {
102
153
  } }
103
154
  } }
104
155
  }, async (ctx) => {
105
- const user = ctx.context.session.user;
156
+ const session = await isSessionOutsideStore(ctx.context.session.session.token) ? ctx.context.session : await getAuthoritativeSessionFromCtx(ctx);
157
+ if (!session) throw APIError.from("UNAUTHORIZED", {
158
+ message: "Unauthorized",
159
+ code: "UNAUTHORIZED"
160
+ });
161
+ const user = session.user;
106
162
  const { password, issuer, method } = ctx.body;
107
163
  if (await shouldRequirePassword(ctx, user.id, allowPasswordless)) {
108
164
  if (!password) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.INVALID_PASSWORD);
@@ -246,36 +302,28 @@ const twoFactor = (options) => {
246
302
  options,
247
303
  hooks: { after: [{
248
304
  matcher(context) {
249
- return context.path === "/sign-in/email" || context.path === "/sign-in/username" || context.path === "/sign-in/phone-number";
305
+ return signInPaths.has(context.path ?? "");
250
306
  },
251
307
  handler: createAuthMiddleware(async (ctx) => {
252
308
  const data = ctx.context.newSession;
253
- if (!data) return;
254
309
  if (!data?.user.twoFactorEnabled) return;
310
+ const { requiresTwoFactor, trustDeviceCookie } = await getSignInTwoFactorState(ctx);
255
311
  const trustDeviceCookieAttrs = ctx.context.createAuthCookie(TRUST_DEVICE_COOKIE_NAME, { maxAge: trustDeviceMaxAge });
256
- const trustDeviceCookie = await ctx.getSignedCookie(trustDeviceCookieAttrs.name, ctx.context.secret);
257
- if (trustDeviceCookie) {
258
- const [token, trustIdentifier] = trustDeviceCookie.split("!");
259
- if (token && trustIdentifier) {
260
- if (token === await createHMAC("SHA-256", "base64urlnopad").sign(ctx.context.secret, `${data.user.id}!${trustIdentifier}`)) {
261
- const verificationRecord = await ctx.context.internalAdapter.findVerificationValue(trustIdentifier);
262
- if (verificationRecord && verificationRecord.value === data.user.id && verificationRecord.expiresAt > /* @__PURE__ */ new Date()) {
263
- await ctx.context.internalAdapter.deleteVerificationByIdentifier(trustIdentifier);
264
- const newTrustIdentifier = `trust-device-${generateRandomString(32)}`;
265
- const newToken = await createHMAC("SHA-256", "base64urlnopad").sign(ctx.context.secret, `${data.user.id}!${newTrustIdentifier}`);
266
- await ctx.context.internalAdapter.createVerificationValue({
267
- value: data.user.id,
268
- identifier: newTrustIdentifier,
269
- expiresAt: new Date(Date.now() + trustDeviceMaxAge * 1e3)
270
- });
271
- const newTrustDeviceCookie = ctx.context.createAuthCookie(TRUST_DEVICE_COOKIE_NAME, { maxAge: trustDeviceMaxAge });
272
- await ctx.setSignedCookie(newTrustDeviceCookie.name, `${newToken}!${newTrustIdentifier}`, ctx.context.secret, trustDeviceCookieAttrs.attributes);
273
- return;
274
- }
275
- }
276
- }
277
- expireCookie(ctx, trustDeviceCookieAttrs);
312
+ if (!requiresTwoFactor && trustDeviceCookie) {
313
+ const trustIdentifier = trustDeviceCookie.split("!")[1];
314
+ await ctx.context.internalAdapter.deleteVerificationByIdentifier(trustIdentifier);
315
+ const newTrustIdentifier = `trust-device-${generateRandomString(32)}`;
316
+ const newToken = await createHMAC("SHA-256", "base64urlnopad").sign(ctx.context.secret, `${data.user.id}!${newTrustIdentifier}`);
317
+ await ctx.context.internalAdapter.createVerificationValue({
318
+ value: data.user.id,
319
+ identifier: newTrustIdentifier,
320
+ expiresAt: new Date(Date.now() + trustDeviceMaxAge * 1e3)
321
+ });
322
+ const newTrustDeviceCookie = ctx.context.createAuthCookie(TRUST_DEVICE_COOKIE_NAME, { maxAge: trustDeviceMaxAge });
323
+ await ctx.setSignedCookie(newTrustDeviceCookie.name, `${newToken}!${newTrustIdentifier}`, ctx.context.secret, trustDeviceCookieAttrs.attributes);
324
+ return;
278
325
  }
326
+ if (trustDeviceCookie) expireCookie(ctx, trustDeviceCookieAttrs);
279
327
  /**
280
328
  * Remove the session cookie set by the credential sign-in.
281
329
  *