better-auth 1.7.15 → 1.7.16

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 (64) 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 +31 -8
  11. package/dist/client/index.d.mts +2 -1
  12. package/dist/db/internal-adapter.mjs +24 -6
  13. package/dist/index.d.mts +2 -2
  14. package/dist/index.mjs +2 -2
  15. package/dist/oauth2/link-account.d.mts +3 -5
  16. package/dist/oauth2/link-account.mjs +33 -3
  17. package/dist/oauth2/state.d.mts +6 -5
  18. package/dist/package.mjs +1 -1
  19. package/dist/plugins/admin/access/statement.d.mts +10 -10
  20. package/dist/plugins/admin/access/statement.mjs +2 -0
  21. package/dist/plugins/admin/admin.d.mts +4 -3
  22. package/dist/plugins/admin/client.d.mts +5 -4
  23. package/dist/plugins/admin/error-codes.d.mts +1 -0
  24. package/dist/plugins/admin/error-codes.mjs +1 -0
  25. package/dist/plugins/admin/routes.mjs +8 -0
  26. package/dist/plugins/anonymous/index.mjs +2 -1
  27. package/dist/plugins/device-authorization/routes.mjs +3 -3
  28. package/dist/plugins/email-otp/routes.mjs +33 -50
  29. package/dist/plugins/generic-oauth/index.mjs +54 -30
  30. package/dist/plugins/index.d.mts +2 -1
  31. package/dist/plugins/index.mjs +2 -1
  32. package/dist/plugins/oauth-proxy/index.mjs +1 -1
  33. package/dist/plugins/one-tap/index.mjs +1 -1
  34. package/dist/plugins/organization/adapter.mjs +1 -0
  35. package/dist/plugins/organization/client.d.mts +1 -0
  36. package/dist/plugins/organization/error-codes.d.mts +1 -0
  37. package/dist/plugins/organization/error-codes.mjs +1 -0
  38. package/dist/plugins/organization/has-permission.d.mts +2 -3
  39. package/dist/plugins/organization/has-permission.mjs +62 -23
  40. package/dist/plugins/organization/index.d.mts +2 -1
  41. package/dist/plugins/organization/index.mjs +2 -1
  42. package/dist/plugins/organization/organization.d.mts +2 -2
  43. package/dist/plugins/organization/organization.mjs +1 -3
  44. package/dist/plugins/organization/permission.mjs +3 -3
  45. package/dist/plugins/organization/roles.d.mts +8 -0
  46. package/dist/plugins/organization/roles.mjs +26 -0
  47. package/dist/plugins/organization/routes/crud-access-control.mjs +2 -1
  48. package/dist/plugins/organization/routes/crud-invites.mjs +22 -11
  49. package/dist/plugins/organization/routes/crud-members.mjs +10 -14
  50. package/dist/plugins/organization/routes/crud-org.mjs +12 -3
  51. package/dist/plugins/phone-number/client.d.mts +2 -0
  52. package/dist/plugins/phone-number/error-codes.d.mts +2 -0
  53. package/dist/plugins/phone-number/error-codes.mjs +2 -0
  54. package/dist/plugins/phone-number/index.d.mts +25 -1
  55. package/dist/plugins/phone-number/index.mjs +22 -6
  56. package/dist/plugins/phone-number/routes.mjs +34 -35
  57. package/dist/plugins/two-factor/index.d.mts +9 -2
  58. package/dist/plugins/two-factor/index.mjs +73 -26
  59. package/dist/plugins/two-factor/verify-two-factor.mjs +2 -2
  60. package/dist/plugins/username/index.mjs +1 -1
  61. package/dist/state.d.mts +6 -0
  62. package/dist/state.mjs +5 -2
  63. package/dist/utils/index.d.mts +2 -2
  64. package/package.json +9 -9
@@ -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] || 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] || 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
  }
@@ -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>;
@@ -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 (!signInPaths.has(ctx.path) || !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,25 @@ 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
+ return (await getSignInTwoFactorState(ctx)).requiresTwoFactor;
94
+ }
51
95
  return {
52
96
  id: "two-factor",
53
97
  version: PACKAGE_VERSION,
98
+ /**
99
+ * Whether this sign-in still requires a second factor.
100
+ *
101
+ * @internal
102
+ */
103
+ shouldRequireTwoFactor,
54
104
  endpoints: {
55
105
  ...totp.endpoints,
56
106
  ...otp.endpoints,
@@ -102,7 +152,12 @@ const twoFactor = (options) => {
102
152
  } }
103
153
  } }
104
154
  }, async (ctx) => {
105
- const user = ctx.context.session.user;
155
+ const session = await isSessionOutsideStore(ctx.context.session.session.token) ? ctx.context.session : await getAuthoritativeSessionFromCtx(ctx);
156
+ if (!session) throw APIError.from("UNAUTHORIZED", {
157
+ message: "Unauthorized",
158
+ code: "UNAUTHORIZED"
159
+ });
160
+ const user = session.user;
106
161
  const { password, issuer, method } = ctx.body;
107
162
  if (await shouldRequirePassword(ctx, user.id, allowPasswordless)) {
108
163
  if (!password) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.INVALID_PASSWORD);
@@ -246,36 +301,28 @@ const twoFactor = (options) => {
246
301
  options,
247
302
  hooks: { after: [{
248
303
  matcher(context) {
249
- return context.path === "/sign-in/email" || context.path === "/sign-in/username" || context.path === "/sign-in/phone-number";
304
+ return signInPaths.has(context.path ?? "");
250
305
  },
251
306
  handler: createAuthMiddleware(async (ctx) => {
252
307
  const data = ctx.context.newSession;
253
- if (!data) return;
254
308
  if (!data?.user.twoFactorEnabled) return;
309
+ const { requiresTwoFactor, trustDeviceCookie } = await getSignInTwoFactorState(ctx);
255
310
  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);
311
+ if (!requiresTwoFactor && trustDeviceCookie) {
312
+ const trustIdentifier = trustDeviceCookie.split("!")[1];
313
+ await ctx.context.internalAdapter.deleteVerificationByIdentifier(trustIdentifier);
314
+ const newTrustIdentifier = `trust-device-${generateRandomString(32)}`;
315
+ const newToken = await createHMAC("SHA-256", "base64urlnopad").sign(ctx.context.secret, `${data.user.id}!${newTrustIdentifier}`);
316
+ await ctx.context.internalAdapter.createVerificationValue({
317
+ value: data.user.id,
318
+ identifier: newTrustIdentifier,
319
+ expiresAt: new Date(Date.now() + trustDeviceMaxAge * 1e3)
320
+ });
321
+ const newTrustDeviceCookie = ctx.context.createAuthCookie(TRUST_DEVICE_COOKIE_NAME, { maxAge: trustDeviceMaxAge });
322
+ await ctx.setSignedCookie(newTrustDeviceCookie.name, `${newToken}!${newTrustIdentifier}`, ctx.context.secret, trustDeviceCookieAttrs.attributes);
323
+ return;
278
324
  }
325
+ if (trustDeviceCookie) expireCookie(ctx, trustDeviceCookieAttrs);
279
326
  /**
280
327
  * Remove the session cookie set by the credential sign-in.
281
328
  *
@@ -1,7 +1,7 @@
1
1
  import { parseUserOutput } from "../../db/schema.mjs";
2
2
  import { generateRandomString } from "../../crypto/random.mjs";
3
3
  import { expireCookie, setSessionCookie } from "../../cookies/index.mjs";
4
- import { getSessionFromCtx } from "../../api/routes/session.mjs";
4
+ import { getAuthoritativeSessionFromCtx } from "../../api/routes/session.mjs";
5
5
  import { TWO_FACTOR_ERROR_CODES } from "./error-code.mjs";
6
6
  import { TRUST_DEVICE_COOKIE_NAME, TWO_FACTOR_COOKIE_NAME } from "./constant.mjs";
7
7
  import { APIError } from "@better-auth/core/error";
@@ -11,7 +11,7 @@ async function verifyTwoFactor(ctx) {
11
11
  const invalid = (errorKey) => {
12
12
  throw APIError.from("UNAUTHORIZED", TWO_FACTOR_ERROR_CODES[errorKey]);
13
13
  };
14
- const session = await getSessionFromCtx(ctx);
14
+ const session = await getAuthoritativeSessionFromCtx(ctx);
15
15
  if (!session) {
16
16
  const twoFactorCookie = ctx.context.createAuthCookie(TWO_FACTOR_COOKIE_NAME);
17
17
  const signedTwoFactorCookie = await ctx.getSignedCookie(twoFactorCookie.name, ctx.context.secret);
@@ -187,7 +187,7 @@ const usernameImpl = (options, includeDisplayUsername) => {
187
187
  if (ctx.context.options?.emailAndPassword?.requireEmailVerification && !user.emailVerified) {
188
188
  if (!ctx.context.options?.emailVerification?.sendVerificationEmail) throw APIError.from("FORBIDDEN", USERNAME_ERROR_CODES.EMAIL_NOT_VERIFIED);
189
189
  if (ctx.context.options?.emailVerification?.sendOnSignIn) {
190
- const token = await createEmailVerificationToken(ctx.context.secret, user.email, void 0, ctx.context.options.emailVerification?.expiresIn);
190
+ const token = await createEmailVerificationToken(ctx.context.secret, user.email, void 0, ctx.context.options.emailVerification?.expiresIn, { userId: user.id });
191
191
  const url = `${ctx.context.baseURL}/verify-email?token=${token}&callbackURL=${encodeURIComponent(ctx.body.callbackURL || "/")}`;
192
192
  await ctx.context.runInBackgroundOrAwait(ctx.context.options.emailVerification.sendVerificationEmail({
193
193
  user,
package/dist/state.d.mts CHANGED
@@ -12,12 +12,17 @@ declare const stateDataSchema: z.ZodObject<{
12
12
  link: z.ZodOptional<z.ZodObject<{
13
13
  email: z.ZodString;
14
14
  userId: z.ZodCoercedString<unknown>;
15
+ encryptedSessionToken: z.ZodOptional<z.ZodString>;
15
16
  }, z.core.$strip>>;
16
17
  requestSignUp: z.ZodOptional<z.ZodBoolean>;
17
18
  idTokenNonce: z.ZodOptional<z.ZodString>;
18
19
  serverContext: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
19
20
  }, z.core.$loose>;
20
21
  export type StateData = z.infer<typeof stateDataSchema>;
22
+ /**
23
+ * Verification key for database-backed OAuth and SAML relay state.
24
+ */
25
+ export declare const getAuthStateVerificationIdentifier: (state: string) => string;
21
26
  export declare function generateGenericState(c: GenericEndpointContext, stateData: StateData, settings?: {
22
27
  cookieName: string;
23
28
  }): Promise<{
@@ -38,6 +43,7 @@ export declare function parseGenericState(c: GenericEndpointContext, state: stri
38
43
  link?: {
39
44
  email: string;
40
45
  userId: string;
46
+ encryptedSessionToken?: string | undefined;
41
47
  } | undefined;
42
48
  requestSignUp?: boolean | undefined;
43
49
  idTokenNonce?: string | undefined;
package/dist/state.mjs CHANGED
@@ -18,7 +18,8 @@ const stateDataSchema = z.looseObject({
18
18
  oauthState: z.string().optional(),
19
19
  link: z.object({
20
20
  email: z.string(),
21
- userId: z.coerce.string()
21
+ userId: z.coerce.string(),
22
+ encryptedSessionToken: z.string().optional()
22
23
  }).optional(),
23
24
  requestSignUp: z.boolean().optional(),
24
25
  /**
@@ -33,7 +34,9 @@ const stateDataSchema = z.looseObject({
33
34
  */
34
35
  serverContext: z.record(z.string(), z.unknown()).optional()
35
36
  });
36
- /** Verification key for database-backed OAuth and SAML relay state. */
37
+ /**
38
+ * Verification key for database-backed OAuth and SAML relay state.
39
+ */
37
40
  const getAuthStateVerificationIdentifier = (state) => `auth-state:${state}`;
38
41
  const INTERNAL_STATE_KEYS = new Set(Object.keys(stateDataSchema.shape));
39
42
  var StateError = class extends BetterAuthError {