better-auth 1.6.16 → 1.6.18

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 (93) hide show
  1. package/dist/api/index.d.mts +2 -2
  2. package/dist/api/index.mjs +3 -4
  3. package/dist/api/middlewares/origin-check.mjs +5 -1
  4. package/dist/api/rate-limiter/index.mjs +259 -73
  5. package/dist/api/routes/account.mjs +22 -7
  6. package/dist/api/routes/callback.mjs +2 -2
  7. package/dist/api/routes/index.d.mts +1 -1
  8. package/dist/api/routes/password.mjs +3 -4
  9. package/dist/api/routes/session.d.mts +12 -1
  10. package/dist/api/routes/session.mjs +13 -1
  11. package/dist/api/routes/sign-in.mjs +5 -5
  12. package/dist/api/routes/sign-up.mjs +2 -2
  13. package/dist/api/routes/update-session.mjs +2 -3
  14. package/dist/api/routes/update-user.mjs +10 -12
  15. package/dist/auth/base.mjs +11 -7
  16. package/dist/client/equality.d.mts +19 -0
  17. package/dist/client/equality.mjs +42 -0
  18. package/dist/client/index.d.mts +5 -4
  19. package/dist/client/index.mjs +2 -1
  20. package/dist/client/lynx/index.d.mts +4 -2
  21. package/dist/client/path-to-object.d.mts +5 -2
  22. package/dist/client/plugins/index.d.mts +4 -1
  23. package/dist/client/plugins/index.mjs +4 -1
  24. package/dist/client/query.d.mts +4 -3
  25. package/dist/client/query.mjs +27 -17
  26. package/dist/client/react/index.d.mts +4 -2
  27. package/dist/client/session-atom.mjs +129 -4
  28. package/dist/client/session-refresh.d.mts +3 -18
  29. package/dist/client/session-refresh.mjs +38 -49
  30. package/dist/client/solid/index.d.mts +4 -2
  31. package/dist/client/svelte/index.d.mts +4 -2
  32. package/dist/client/types.d.mts +27 -16
  33. package/dist/client/vanilla.d.mts +4 -2
  34. package/dist/client/vue/index.d.mts +4 -2
  35. package/dist/context/create-context.mjs +2 -1
  36. package/dist/context/store-capabilities.mjs +12 -0
  37. package/dist/cookies/index.mjs +25 -2
  38. package/dist/db/internal-adapter.mjs +51 -0
  39. package/dist/package.mjs +1 -1
  40. package/dist/plugins/access/access.mjs +49 -19
  41. package/dist/plugins/admin/routes.mjs +10 -3
  42. package/dist/plugins/captcha/constants.mjs +8 -1
  43. package/dist/plugins/captcha/index.mjs +8 -2
  44. package/dist/plugins/captcha/types.d.mts +21 -0
  45. package/dist/plugins/captcha/verify-handlers/captchafox.mjs +2 -0
  46. package/dist/plugins/captcha/verify-handlers/cloudflare-turnstile.mjs +7 -2
  47. package/dist/plugins/captcha/verify-handlers/google-recaptcha.mjs +7 -2
  48. package/dist/plugins/captcha/verify-handlers/h-captcha.mjs +2 -0
  49. package/dist/plugins/device-authorization/routes.mjs +16 -9
  50. package/dist/plugins/email-otp/routes.mjs +22 -52
  51. package/dist/plugins/generic-oauth/index.mjs +7 -2
  52. package/dist/plugins/generic-oauth/routes.mjs +16 -12
  53. package/dist/plugins/haveibeenpwned/index.d.mts +1 -1
  54. package/dist/plugins/haveibeenpwned/index.mjs +5 -1
  55. package/dist/plugins/index.d.mts +6 -2
  56. package/dist/plugins/index.mjs +4 -1
  57. package/dist/plugins/jwt/index.mjs +2 -2
  58. package/dist/plugins/mcp/client/index.mjs +1 -0
  59. package/dist/plugins/mcp/index.mjs +8 -0
  60. package/dist/plugins/multi-session/index.mjs +7 -5
  61. package/dist/plugins/oauth-popup/client.d.mts +82 -0
  62. package/dist/plugins/oauth-popup/client.mjs +203 -0
  63. package/dist/plugins/oauth-popup/constants.d.mts +11 -0
  64. package/dist/plugins/oauth-popup/constants.mjs +11 -0
  65. package/dist/plugins/oauth-popup/error-codes.d.mts +11 -0
  66. package/dist/plugins/oauth-popup/error-codes.mjs +10 -0
  67. package/dist/plugins/oauth-popup/index.d.mts +67 -0
  68. package/dist/plugins/oauth-popup/index.mjs +227 -0
  69. package/dist/plugins/oauth-popup/types.d.mts +30 -0
  70. package/dist/plugins/oauth-proxy/index.mjs +2 -2
  71. package/dist/plugins/oauth-proxy/utils.mjs +16 -2
  72. package/dist/plugins/oidc-provider/index.mjs +10 -0
  73. package/dist/plugins/one-tap/client.mjs +12 -6
  74. package/dist/plugins/one-tap/index.d.mts +1 -0
  75. package/dist/plugins/one-tap/index.mjs +9 -5
  76. package/dist/plugins/one-time-token/index.mjs +1 -3
  77. package/dist/plugins/open-api/generator.d.mts +66 -57
  78. package/dist/plugins/open-api/generator.mjs +185 -67
  79. package/dist/plugins/open-api/index.d.mts +2 -2
  80. package/dist/plugins/organization/adapter.d.mts +29 -1
  81. package/dist/plugins/organization/adapter.mjs +66 -6
  82. package/dist/plugins/organization/routes/crud-invites.mjs +49 -34
  83. package/dist/plugins/organization/routes/crud-members.mjs +42 -6
  84. package/dist/plugins/organization/routes/crud-team.mjs +36 -3
  85. package/dist/plugins/phone-number/routes.mjs +41 -36
  86. package/dist/plugins/siwe/index.mjs +2 -3
  87. package/dist/plugins/two-factor/backup-codes/index.mjs +1 -1
  88. package/dist/plugins/two-factor/otp/index.mjs +11 -13
  89. package/dist/plugins/two-factor/totp/index.mjs +1 -1
  90. package/dist/plugins/two-factor/verify-two-factor.mjs +6 -2
  91. package/dist/plugins/username/index.mjs +6 -6
  92. package/dist/test-utils/test-instance.d.mts +26 -23
  93. package/package.json +9 -9
@@ -536,6 +536,28 @@ declare const getOrgAdapter: <O extends OrganizationOptions>(context: AuthContex
536
536
  userId: string;
537
537
  createdAt: Date;
538
538
  }>;
539
+ /**
540
+ * Adds a user to a team only when the team is below its member limit,
541
+ * reading the count and creating the membership in one transaction.
542
+ * Returns the existing membership unchanged (no capacity charge) when the
543
+ * user already belongs to the team.
544
+ *
545
+ * FIXME(team-cap-race): the count-then-create is not atomic under READ
546
+ * COMMITTED, so two concurrent adds can both pass the count check and
547
+ * exceed maximumMembersPerTeam. A durable fix needs a unique constraint on
548
+ * teamMember(teamId, userId) or serializable isolation. Affects every
549
+ * caller (acceptInvitation, addMember, addTeamMember).
550
+ */
551
+ addTeamMemberWithLimit: (data: {
552
+ teamId: string;
553
+ userId: string;
554
+ maximumMembersPerTeam: number;
555
+ }) => Promise<{
556
+ status: "added";
557
+ member: TeamMember;
558
+ } | {
559
+ status: "limitReached";
560
+ }>;
539
561
  removeTeamMember: (data: {
540
562
  teamId: string;
541
563
  userId: string;
@@ -757,7 +779,13 @@ declare const getOrgAdapter: <O extends OrganizationOptions>(context: AuthContex
757
779
  } ? FieldAttributeToObject<Field> : {}) extends infer T ? { [K in keyof T]: T[K] } : never)[]>;
758
780
  updateInvitation: (data: {
759
781
  invitationId: string;
760
- status: "accepted" | "canceled" | "rejected";
782
+ status: "pending" | "accepted" | "canceled" | "rejected";
783
+ /**
784
+ * Only transition when the invitation is currently in this status. The
785
+ * guarded update is atomic, so a concurrent caller racing the same
786
+ * transition gets `null` instead of both proceeding.
787
+ */
788
+ fromStatus?: "pending";
761
789
  }) => Promise<((O["teams"] extends {
762
790
  enabled: true;
763
791
  } ? {
@@ -4,6 +4,23 @@ import { getCurrentAdapter, runWithTransaction } from "@better-auth/core/context
4
4
  import { BetterAuthError } from "@better-auth/core/error";
5
5
  import { filterOutputFields } from "@better-auth/core/utils/db";
6
6
  //#region src/plugins/organization/adapter.ts
7
+ /**
8
+ * Resolves the configured per-team member cap to a concrete number for a given
9
+ * team-add. Returns `undefined` only when no cap is configured. Throws when the
10
+ * cap is a function but no session is available to evaluate it, so a sessionless
11
+ * server-side add fails closed instead of silently bypassing the limit.
12
+ */
13
+ async function resolveMaximumMembersPerTeam(teams, context) {
14
+ const maximumMembersPerTeam = teams?.maximumMembersPerTeam;
15
+ if (maximumMembersPerTeam === void 0) return void 0;
16
+ if (typeof maximumMembersPerTeam === "number") return maximumMembersPerTeam;
17
+ if (!context.session) throw new BetterAuthError("`teams.maximumMembersPerTeam` is configured as a function but no session is available to evaluate it. Provide a session-bearing request or configure a numeric limit.");
18
+ return await maximumMembersPerTeam({
19
+ teamId: context.teamId,
20
+ session: context.session,
21
+ organizationId: context.organizationId
22
+ });
23
+ }
7
24
  const getOrgAdapter = (context, options) => {
8
25
  const baseAdapter = context.adapter;
9
26
  const orgAdditionalFields = options?.schema?.organization?.additionalFields;
@@ -513,6 +530,43 @@ const getOrgAdapter = (context, options) => {
513
530
  }
514
531
  });
515
532
  },
533
+ addTeamMemberWithLimit: async (data) => {
534
+ return runWithTransaction(baseAdapter, async () => {
535
+ const adapter = await getCurrentAdapter(baseAdapter);
536
+ const existing = await adapter.findOne({
537
+ model: "teamMember",
538
+ where: [{
539
+ field: "teamId",
540
+ value: data.teamId
541
+ }, {
542
+ field: "userId",
543
+ value: data.userId
544
+ }]
545
+ });
546
+ if (existing) return {
547
+ status: "added",
548
+ member: existing
549
+ };
550
+ if (await adapter.count({
551
+ model: "teamMember",
552
+ where: [{
553
+ field: "teamId",
554
+ value: data.teamId
555
+ }]
556
+ }) >= data.maximumMembersPerTeam) return { status: "limitReached" };
557
+ return {
558
+ status: "added",
559
+ member: await adapter.create({
560
+ model: "teamMember",
561
+ data: {
562
+ teamId: data.teamId,
563
+ userId: data.userId,
564
+ createdAt: /* @__PURE__ */ new Date()
565
+ }
566
+ })
567
+ };
568
+ });
569
+ },
516
570
  removeTeamMember: async (data) => {
517
571
  await (await getCurrentAdapter(baseAdapter)).deleteMany({
518
572
  model: "teamMember",
@@ -615,16 +669,22 @@ const getOrgAdapter = (context, options) => {
615
669
  });
616
670
  },
617
671
  updateInvitation: async (data) => {
618
- return await (await getCurrentAdapter(baseAdapter)).update({
672
+ const adapter = await getCurrentAdapter(baseAdapter);
673
+ const where = [{
674
+ field: "id",
675
+ value: data.invitationId
676
+ }];
677
+ if (data.fromStatus) where.push({
678
+ field: "status",
679
+ value: data.fromStatus
680
+ });
681
+ return await adapter.update({
619
682
  model: "invitation",
620
- where: [{
621
- field: "id",
622
- value: data.invitationId
623
- }],
683
+ where,
624
684
  update: { status: data.status }
625
685
  });
626
686
  }
627
687
  };
628
688
  };
629
689
  //#endregion
630
- export { getOrgAdapter };
690
+ export { getOrgAdapter, resolveMaximumMembersPerTeam };
@@ -4,10 +4,11 @@ import { toZodSchema } from "../../../db/to-zod.mjs";
4
4
  import { getSessionFromCtx } from "../../../api/routes/session.mjs";
5
5
  import { defaultRoles } from "../access/statement.mjs";
6
6
  import { ORGANIZATION_ERROR_CODES } from "../error-codes.mjs";
7
- import { getOrgAdapter } from "../adapter.mjs";
7
+ import { getOrgAdapter, resolveMaximumMembersPerTeam } from "../adapter.mjs";
8
8
  import { orgMiddleware, orgSessionMiddleware } from "../call.mjs";
9
9
  import { hasPermission } from "../has-permission.mjs";
10
10
  import { parseRoles } from "../organization.mjs";
11
+ import { runWithTransaction } from "@better-auth/core/context";
11
12
  import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
12
13
  import { createAuthEndpoint } from "@better-auth/core/api";
13
14
  import * as z from "zod";
@@ -283,44 +284,58 @@ const acceptInvitation = (options) => createAuthEndpoint("/organization/accept-i
283
284
  });
284
285
  const acceptedI = await adapter.updateInvitation({
285
286
  invitationId: ctx.body.invitationId,
286
- status: "accepted"
287
+ status: "accepted",
288
+ fromStatus: "pending"
287
289
  });
288
- if (!acceptedI) throw APIError.from("BAD_REQUEST", ORGANIZATION_ERROR_CODES.FAILED_TO_RETRIEVE_INVITATION);
289
- if (ctx.context.orgOptions.teams && ctx.context.orgOptions.teams.enabled && "teamId" in acceptedI && acceptedI.teamId) {
290
- const teamIds = acceptedI.teamId.split(",");
291
- const onlyOne = teamIds.length === 1;
292
- for (const teamId of teamIds) {
293
- if (!await adapter.findTeamById({
294
- teamId,
295
- organizationId: invitation.organizationId
296
- })) throw APIError.from("BAD_REQUEST", ORGANIZATION_ERROR_CODES.TEAM_NOT_FOUND);
297
- await adapter.findOrCreateTeamMember({
298
- teamId,
299
- userId: session.user.id
300
- });
301
- if (typeof ctx.context.orgOptions.teams.maximumMembersPerTeam !== "undefined") {
302
- if (await adapter.countTeamMembers({ teamId }) >= (typeof ctx.context.orgOptions.teams.maximumMembersPerTeam === "function" ? await ctx.context.orgOptions.teams.maximumMembersPerTeam({
290
+ if (!acceptedI) throw APIError.from("BAD_REQUEST", ORGANIZATION_ERROR_CODES.INVITATION_NOT_FOUND);
291
+ const member = await runWithTransaction(ctx.context.adapter, async () => {
292
+ if (ctx.context.orgOptions.teams && ctx.context.orgOptions.teams.enabled && "teamId" in acceptedI && acceptedI.teamId) {
293
+ const teamIds = acceptedI.teamId.split(",");
294
+ const onlyOne = teamIds.length === 1;
295
+ for (const teamId of teamIds) {
296
+ if (!await adapter.findTeamById({
303
297
  teamId,
304
- session,
305
- organizationId: invitation.organizationId
306
- }) : ctx.context.orgOptions.teams.maximumMembersPerTeam)) throw APIError.from("FORBIDDEN", ORGANIZATION_ERROR_CODES.TEAM_MEMBER_LIMIT_REACHED);
298
+ organizationId: acceptedI.organizationId
299
+ })) throw APIError.from("BAD_REQUEST", ORGANIZATION_ERROR_CODES.TEAM_NOT_FOUND);
300
+ const maximumMembersPerTeam = await resolveMaximumMembersPerTeam(ctx.context.orgOptions.teams, {
301
+ teamId,
302
+ organizationId: acceptedI.organizationId,
303
+ session
304
+ });
305
+ if (maximumMembersPerTeam !== void 0) {
306
+ if ((await adapter.addTeamMemberWithLimit({
307
+ teamId,
308
+ userId: session.user.id,
309
+ maximumMembersPerTeam
310
+ })).status === "limitReached") throw APIError.from("FORBIDDEN", ORGANIZATION_ERROR_CODES.TEAM_MEMBER_LIMIT_REACHED);
311
+ } else await adapter.findOrCreateTeamMember({
312
+ teamId,
313
+ userId: session.user.id
314
+ });
315
+ }
316
+ if (onlyOne) {
317
+ const teamId = teamIds[0];
318
+ await setSessionCookie(ctx, {
319
+ session: await adapter.setActiveTeam(session.session.token, teamId, ctx),
320
+ user: session.user
321
+ });
307
322
  }
308
323
  }
309
- if (onlyOne) {
310
- const teamId = teamIds[0];
311
- await setSessionCookie(ctx, {
312
- session: await adapter.setActiveTeam(session.session.token, teamId, ctx),
313
- user: session.user
314
- });
315
- }
316
- }
317
- const member = await adapter.createMember({
318
- organizationId: invitation.organizationId,
319
- userId: session.user.id,
320
- role: invitation.role,
321
- createdAt: /* @__PURE__ */ new Date()
324
+ const createdMember = await adapter.createMember({
325
+ organizationId: acceptedI.organizationId,
326
+ userId: session.user.id,
327
+ role: acceptedI.role,
328
+ createdAt: /* @__PURE__ */ new Date()
329
+ });
330
+ await adapter.setActiveOrganization(session.session.token, acceptedI.organizationId, ctx);
331
+ return createdMember;
332
+ }).catch(async (error) => {
333
+ await adapter.updateInvitation({
334
+ invitationId: ctx.body.invitationId,
335
+ status: "pending"
336
+ });
337
+ throw error;
322
338
  });
323
- await adapter.setActiveOrganization(session.session.token, invitation.organizationId, ctx);
324
339
  if (options?.organizationHooks?.afterAcceptInvitation) await options?.organizationHooks.afterAcceptInvitation({
325
340
  invitation: acceptedI,
326
341
  member,
@@ -1,7 +1,8 @@
1
1
  import { toZodSchema } from "../../../db/to-zod.mjs";
2
2
  import { getSessionFromCtx, sessionMiddleware } from "../../../api/routes/session.mjs";
3
+ import { defaultRoles } from "../access/statement.mjs";
3
4
  import { ORGANIZATION_ERROR_CODES } from "../error-codes.mjs";
4
- import { getOrgAdapter } from "../adapter.mjs";
5
+ import { getOrgAdapter, resolveMaximumMembersPerTeam } from "../adapter.mjs";
5
6
  import { orgMiddleware, orgSessionMiddleware } from "../call.mjs";
6
7
  import { hasPermission } from "../has-permission.mjs";
7
8
  import { parseRoles } from "../organization.mjs";
@@ -21,7 +22,7 @@ const addMember = (option) => {
21
22
  fields: option?.schema?.member?.additionalFields || {},
22
23
  isClientSide: true
23
24
  });
24
- return createAuthEndpoint({
25
+ return createAuthEndpoint.serverOnly({
25
26
  method: "POST",
26
27
  body: z.object({
27
28
  ...baseMemberSchema.shape,
@@ -87,11 +88,22 @@ const addMember = (option) => {
87
88
  ...response.data
88
89
  };
89
90
  }
90
- const createdMember = await adapter.createMember(memberData);
91
- if (teamId) await adapter.findOrCreateTeamMember({
91
+ const maximumMembersPerTeam = teamId ? await resolveMaximumMembersPerTeam(ctx.context.orgOptions.teams, {
92
+ teamId,
93
+ organizationId: orgId,
94
+ session
95
+ }) : void 0;
96
+ if (teamId) if (maximumMembersPerTeam !== void 0) {
97
+ if ((await adapter.addTeamMemberWithLimit({
98
+ teamId,
99
+ userId: user.id,
100
+ maximumMembersPerTeam
101
+ })).status === "limitReached") throw APIError.from("FORBIDDEN", ORGANIZATION_ERROR_CODES.TEAM_MEMBER_LIMIT_REACHED);
102
+ } else await adapter.findOrCreateTeamMember({
92
103
  userId: user.id,
93
104
  teamId
94
105
  });
106
+ const createdMember = await adapter.createMember(memberData);
95
107
  if (option?.organizationHooks?.afterAddMember) await option?.organizationHooks.afterAddMember({
96
108
  member: createdMember,
97
109
  user,
@@ -241,7 +253,31 @@ const updateMemberRole = (option) => createAuthEndpoint("/organization/update-me
241
253
  const organizationId = ctx.body.organizationId || session.session.activeOrganizationId;
242
254
  if (!organizationId) throw APIError.from("BAD_REQUEST", ORGANIZATION_ERROR_CODES.NO_ACTIVE_ORGANIZATION);
243
255
  const adapter = getOrgAdapter(ctx.context, ctx.context.orgOptions);
244
- const roleToSet = Array.isArray(ctx.body.role) ? ctx.body.role : ctx.body.role ? [ctx.body.role] : [];
256
+ const roleToSet = (Array.isArray(ctx.body.role) ? ctx.body.role : [ctx.body.role]).flatMap((role) => role.split(",")).map((role) => role.trim()).filter(Boolean);
257
+ if (roleToSet.length === 0) throw APIError.fromStatus("BAD_REQUEST");
258
+ const validStaticRoles = new Set([...Object.keys(defaultRoles), ...Object.keys(ctx.context.orgOptions.roles || {})]);
259
+ const unknownRoles = roleToSet.filter((role) => !validStaticRoles.has(role));
260
+ if (unknownRoles.length > 0) if (ctx.context.orgOptions.dynamicAccessControl?.enabled) {
261
+ const foundRoleNames = (await ctx.context.adapter.findMany({
262
+ model: "organizationRole",
263
+ where: [{
264
+ field: "organizationId",
265
+ value: organizationId
266
+ }, {
267
+ field: "role",
268
+ value: unknownRoles,
269
+ operator: "in"
270
+ }]
271
+ })).map((r) => r.role);
272
+ const stillInvalid = unknownRoles.filter((r) => !foundRoleNames.includes(r));
273
+ if (stillInvalid.length > 0) throw new APIError("BAD_REQUEST", {
274
+ code: ORGANIZATION_ERROR_CODES.ROLE_NOT_FOUND.code,
275
+ message: `${ORGANIZATION_ERROR_CODES.ROLE_NOT_FOUND.code}: ${stillInvalid.join(", ")}`
276
+ });
277
+ } else throw new APIError("BAD_REQUEST", {
278
+ code: ORGANIZATION_ERROR_CODES.ROLE_NOT_FOUND.code,
279
+ message: `${ORGANIZATION_ERROR_CODES.ROLE_NOT_FOUND.code}: ${unknownRoles.join(", ")}`
280
+ });
245
281
  const member = await adapter.findMemberByOrgId({
246
282
  userId: session.user.id,
247
283
  organizationId
@@ -280,7 +316,7 @@ const updateMemberRole = (option) => createAuthEndpoint("/organization/update-me
280
316
  const userBeingUpdated = await ctx.context.internalAdapter.findUserById(toBeUpdatedMember.userId);
281
317
  if (!userBeingUpdated) throw APIError.fromStatus("BAD_REQUEST", { message: "User not found" });
282
318
  const previousRole = toBeUpdatedMember.role;
283
- const newRole = parseRoles(ctx.body.role);
319
+ const newRole = parseRoles(roleToSet);
284
320
  if (option?.organizationHooks?.beforeUpdateMemberRole) {
285
321
  const response = await option?.organizationHooks.beforeUpdateMemberRole({
286
322
  member: toBeUpdatedMember,
@@ -2,10 +2,11 @@ import { setSessionCookie } from "../../../cookies/index.mjs";
2
2
  import { toZodSchema } from "../../../db/to-zod.mjs";
3
3
  import { getSessionFromCtx } from "../../../api/routes/session.mjs";
4
4
  import { ORGANIZATION_ERROR_CODES } from "../error-codes.mjs";
5
- import { getOrgAdapter } from "../adapter.mjs";
5
+ import { getOrgAdapter, resolveMaximumMembersPerTeam } from "../adapter.mjs";
6
6
  import { orgMiddleware, orgSessionMiddleware } from "../call.mjs";
7
7
  import { hasPermission } from "../has-permission.mjs";
8
8
  import { teamSchema } from "../schema.mjs";
9
+ import { getCurrentAdapter, runWithTransaction } from "@better-auth/core/context";
9
10
  import { APIError } from "@better-auth/core/error";
10
11
  import { createAuthEndpoint } from "@better-auth/core/api";
11
12
  import * as z from "zod";
@@ -185,7 +186,25 @@ const removeTeam = (options) => createAuthEndpoint("/organization/remove-team",
185
186
  user: session?.user,
186
187
  organization
187
188
  });
188
- await adapter.deleteTeam(team.id);
189
+ await runWithTransaction(ctx.context.adapter, async () => {
190
+ await adapter.deleteTeam(team.id);
191
+ const pendingInvitations = await adapter.findPendingInvitations({ organizationId });
192
+ const trx = await getCurrentAdapter(ctx.context.adapter);
193
+ for (const invitation of pendingInvitations) {
194
+ if (!("teamId" in invitation) || !invitation.teamId) continue;
195
+ const teamIds = invitation.teamId.split(",");
196
+ if (!teamIds.includes(team.id)) continue;
197
+ const remainingTeamIds = teamIds.filter((id) => id !== team.id);
198
+ await trx.update({
199
+ model: "invitation",
200
+ where: [{
201
+ field: "id",
202
+ value: invitation.id
203
+ }],
204
+ update: { teamId: remainingTeamIds.length > 0 ? remainingTeamIds.join(",") : null }
205
+ });
206
+ }
207
+ });
189
208
  if (options?.organizationHooks?.afterDeleteTeam) await options?.organizationHooks.afterDeleteTeam({
190
209
  team,
191
210
  user: session?.user,
@@ -600,7 +619,21 @@ const addTeamMember = (options) => createAuthEndpoint("/organization/add-team-me
600
619
  });
601
620
  if (response && typeof response === "object" && "data" in response) {}
602
621
  }
603
- const teamMember = await adapter.findOrCreateTeamMember({
622
+ const maximumMembersPerTeam = await resolveMaximumMembersPerTeam(ctx.context.orgOptions.teams, {
623
+ teamId: ctx.body.teamId,
624
+ organizationId,
625
+ session
626
+ });
627
+ let teamMember;
628
+ if (maximumMembersPerTeam !== void 0) {
629
+ const result = await adapter.addTeamMemberWithLimit({
630
+ teamId: ctx.body.teamId,
631
+ userId: ctx.body.userId,
632
+ maximumMembersPerTeam
633
+ });
634
+ if (result.status === "limitReached") throw APIError.from("FORBIDDEN", ORGANIZATION_ERROR_CODES.TEAM_MEMBER_LIMIT_REACHED);
635
+ teamMember = result.member;
636
+ } else teamMember = await adapter.findOrCreateTeamMember({
604
637
  teamId: ctx.body.teamId,
605
638
  userId: ctx.body.userId
606
639
  });
@@ -78,19 +78,19 @@ const signInPhoneNumber = (opts) => createAuthEndpoint("/sign-in/phone-number",
78
78
  }
79
79
  const credentialAccount = (await ctx.context.internalAdapter.findAccountByUserId(user.id)).find((a) => a.providerId === "credential");
80
80
  if (!credentialAccount) {
81
- ctx.context.logger.error("Credential account not found", { phoneNumber });
81
+ ctx.context.logger.warn("Credential account not found");
82
82
  throw APIError.from("UNAUTHORIZED", PHONE_NUMBER_ERROR_CODES.INVALID_PHONE_NUMBER_OR_PASSWORD);
83
83
  }
84
84
  const currentPassword = credentialAccount?.password;
85
85
  if (!currentPassword) {
86
- ctx.context.logger.error("Password not found", { phoneNumber });
86
+ ctx.context.logger.warn("Password not found");
87
87
  throw APIError.from("UNAUTHORIZED", PHONE_NUMBER_ERROR_CODES.UNEXPECTED_ERROR);
88
88
  }
89
89
  if (!await ctx.context.password.verify({
90
90
  hash: currentPassword,
91
91
  password
92
92
  })) {
93
- ctx.context.logger.error("Invalid password");
93
+ ctx.context.logger.warn("Invalid password");
94
94
  throw APIError.from("UNAUTHORIZED", PHONE_NUMBER_ERROR_CODES.INVALID_PHONE_NUMBER_OR_PASSWORD);
95
95
  }
96
96
  const session = await ctx.context.internalAdapter.createSession(user.id, ctx.body.rememberMe === false);
@@ -280,24 +280,7 @@ const verifyPhoneNumber = (opts) => createAuthEndpoint("/phone-number/verify", {
280
280
  code: ctx.body.code
281
281
  }, ctx)) throw APIError.from("BAD_REQUEST", PHONE_NUMBER_ERROR_CODES.INVALID_OTP);
282
282
  if (await ctx.context.internalAdapter.findVerificationValue(ctx.body.phoneNumber)) await ctx.context.internalAdapter.deleteVerificationByIdentifier(ctx.body.phoneNumber);
283
- } else {
284
- const otp = await ctx.context.internalAdapter.findVerificationValue(ctx.body.phoneNumber);
285
- if (!otp || otp.expiresAt < /* @__PURE__ */ new Date()) {
286
- if (otp && otp.expiresAt < /* @__PURE__ */ new Date()) throw APIError.from("BAD_REQUEST", PHONE_NUMBER_ERROR_CODES.OTP_EXPIRED);
287
- throw APIError.from("BAD_REQUEST", PHONE_NUMBER_ERROR_CODES.OTP_NOT_FOUND);
288
- }
289
- const [otpValue, attempts] = otp.value.split(":");
290
- const allowedAttempts = opts?.allowedAttempts || 3;
291
- if (attempts && parseInt(attempts) >= allowedAttempts) {
292
- await ctx.context.internalAdapter.deleteVerificationByIdentifier(ctx.body.phoneNumber);
293
- throw APIError.from("FORBIDDEN", PHONE_NUMBER_ERROR_CODES.TOO_MANY_ATTEMPTS);
294
- }
295
- if (otpValue !== ctx.body.code) {
296
- await ctx.context.internalAdapter.updateVerificationByIdentifier(ctx.body.phoneNumber, { value: `${otpValue}:${parseInt(attempts || "0") + 1}` });
297
- throw APIError.from("BAD_REQUEST", PHONE_NUMBER_ERROR_CODES.INVALID_OTP);
298
- }
299
- await ctx.context.internalAdapter.deleteVerificationByIdentifier(ctx.body.phoneNumber);
300
- }
283
+ } else await verifyPhoneNumberOTP(ctx, opts, ctx.body.phoneNumber, ctx.body.code);
301
284
  if (ctx.body.updatePhoneNumber) {
302
285
  const session = await getSessionFromCtx(ctx);
303
286
  if (!session) throw APIError.from("UNAUTHORIZED", BASE_ERROR_CODES.USER_NOT_FOUND);
@@ -432,20 +415,7 @@ const resetPasswordPhoneNumber = (opts) => createAuthEndpoint("/phone-number/res
432
415
  } }
433
416
  } }
434
417
  }, async (ctx) => {
435
- const verification = await ctx.context.internalAdapter.findVerificationValue(`${ctx.body.phoneNumber}-request-password-reset`);
436
- if (!verification) throw APIError.from("BAD_REQUEST", PHONE_NUMBER_ERROR_CODES.OTP_NOT_FOUND);
437
- if (verification.expiresAt < /* @__PURE__ */ new Date()) throw APIError.from("BAD_REQUEST", PHONE_NUMBER_ERROR_CODES.OTP_EXPIRED);
438
- const [otpValue, attempts] = verification.value.split(":");
439
- const allowedAttempts = opts?.allowedAttempts || 3;
440
- const phoneResetIdentifier = `${ctx.body.phoneNumber}-request-password-reset`;
441
- if (attempts && parseInt(attempts) >= allowedAttempts) {
442
- await ctx.context.internalAdapter.deleteVerificationByIdentifier(phoneResetIdentifier);
443
- throw APIError.from("FORBIDDEN", PHONE_NUMBER_ERROR_CODES.TOO_MANY_ATTEMPTS);
444
- }
445
- if (ctx.body.otp !== otpValue) {
446
- await ctx.context.internalAdapter.updateVerificationByIdentifier(phoneResetIdentifier, { value: `${otpValue}:${parseInt(attempts || "0") + 1}` });
447
- throw APIError.from("BAD_REQUEST", PHONE_NUMBER_ERROR_CODES.INVALID_OTP);
448
- }
418
+ await verifyPhoneNumberOTP(ctx, opts, `${ctx.body.phoneNumber}-request-password-reset`, ctx.body.otp);
449
419
  const userRes = await ctx.context.adapter.findOne({
450
420
  model: "user",
451
421
  where: [{
@@ -468,11 +438,46 @@ const resetPasswordPhoneNumber = (opts) => createAuthEndpoint("/phone-number/res
468
438
  password: hashedPassword
469
439
  });
470
440
  else await ctx.context.internalAdapter.updatePassword(user.id, hashedPassword);
471
- await ctx.context.internalAdapter.deleteVerificationByIdentifier(phoneResetIdentifier);
472
441
  if (ctx.context.options.emailAndPassword?.onPasswordReset) await ctx.context.options.emailAndPassword.onPasswordReset({ user }, ctx.request);
473
442
  if (ctx.context.options.emailAndPassword?.revokeSessionsOnPasswordReset) await ctx.context.internalAdapter.deleteUserSessions(user.id);
474
443
  return ctx.json({ status: true });
475
444
  });
445
+ /**
446
+ * Atomically verifies a phone-number OTP against a stored verification value.
447
+ *
448
+ * Consuming the row is the race gate: the first concurrent caller wins the
449
+ * row, every racer behind it gets `null` and is rejected, so the same code can
450
+ * never satisfy two simultaneous verifications. On a wrong code that is still
451
+ * within the attempt budget, the row is recreated with the same value and
452
+ * expiry and an incremented attempt counter. Once the budget is exhausted the
453
+ * row is not recreated.
454
+ */
455
+ async function verifyPhoneNumberOTP(ctx, opts, identifier, providedCode) {
456
+ const existing = await ctx.context.internalAdapter.findVerificationValue(identifier);
457
+ if (!existing) throw APIError.from("BAD_REQUEST", PHONE_NUMBER_ERROR_CODES.OTP_NOT_FOUND);
458
+ if (existing.expiresAt < /* @__PURE__ */ new Date()) {
459
+ await ctx.context.internalAdapter.deleteVerificationByIdentifier(identifier);
460
+ throw APIError.from("BAD_REQUEST", PHONE_NUMBER_ERROR_CODES.OTP_EXPIRED);
461
+ }
462
+ const allowedAttempts = opts?.allowedAttempts || 3;
463
+ const [, peekedAttempts] = existing.value.split(":");
464
+ if (peekedAttempts && parseInt(peekedAttempts) >= allowedAttempts) {
465
+ await ctx.context.internalAdapter.deleteVerificationByIdentifier(identifier);
466
+ throw APIError.from("FORBIDDEN", PHONE_NUMBER_ERROR_CODES.TOO_MANY_ATTEMPTS);
467
+ }
468
+ const consumed = await ctx.context.internalAdapter.consumeVerificationValue(identifier);
469
+ if (!consumed) throw APIError.from("BAD_REQUEST", PHONE_NUMBER_ERROR_CODES.INVALID_OTP);
470
+ const [otpValue, attempts] = consumed.value.split(":");
471
+ if (attempts && parseInt(attempts) >= allowedAttempts) throw APIError.from("FORBIDDEN", PHONE_NUMBER_ERROR_CODES.TOO_MANY_ATTEMPTS);
472
+ if (otpValue !== providedCode) {
473
+ await ctx.context.internalAdapter.createVerificationValue({
474
+ value: `${otpValue}:${parseInt(attempts || "0") + 1}`,
475
+ identifier,
476
+ expiresAt: consumed.expiresAt
477
+ });
478
+ throw APIError.from("BAD_REQUEST", PHONE_NUMBER_ERROR_CODES.INVALID_OTP);
479
+ }
480
+ }
476
481
  function generateOTP(size) {
477
482
  return generateRandomString(size, "0-9");
478
483
  }
@@ -68,8 +68,8 @@ const siwe = (options) => {
68
68
  status: 400
69
69
  });
70
70
  try {
71
- const verification = await ctx.context.internalAdapter.findVerificationValue(`siwe:${walletAddress}:${chainId}`);
72
- if (!verification || /* @__PURE__ */ new Date() > verification.expiresAt) throw APIError.fromStatus("UNAUTHORIZED", {
71
+ const verification = await ctx.context.internalAdapter.consumeVerificationValue(`siwe:${walletAddress}:${chainId}`);
72
+ if (!verification) throw APIError.fromStatus("UNAUTHORIZED", {
73
73
  message: "Unauthorized: Invalid or expired nonce",
74
74
  status: 401,
75
75
  code: "UNAUTHORIZED_INVALID_OR_EXPIRED_NONCE"
@@ -125,7 +125,6 @@ const siwe = (options) => {
125
125
  message: "Unauthorized: Invalid SIWE signature",
126
126
  status: 401
127
127
  });
128
- await ctx.context.internalAdapter.deleteVerificationByIdentifier(`siwe:${walletAddress}:${chainId}`);
129
128
  let user = null;
130
129
  const existingWalletAddress = await ctx.context.adapter.findOne({
131
130
  model: "walletAddress",
@@ -249,7 +249,7 @@ const backupCode2fa = (opts) => {
249
249
  backupCodes: backupCodes.backupCodes
250
250
  });
251
251
  }),
252
- viewBackupCodes: createAuthEndpoint({
252
+ viewBackupCodes: createAuthEndpoint.serverOnly({
253
253
  method: "POST",
254
254
  body: viewBackupCodesBodySchema
255
255
  }, async (ctx) => {
@@ -158,17 +158,12 @@ const otp2fa = (options) => {
158
158
  } }
159
159
  }, async (ctx) => {
160
160
  const { session, key, valid, invalid } = await verifyTwoFactor(ctx);
161
- const toCheckOtp = await ctx.context.internalAdapter.findVerificationValue(`2fa-otp-${key}`);
162
- const [otp, counter] = toCheckOtp?.value?.split(":") ?? [];
163
- if (!toCheckOtp || toCheckOtp.expiresAt < /* @__PURE__ */ new Date()) {
164
- if (toCheckOtp) await ctx.context.internalAdapter.deleteVerificationByIdentifier(`2fa-otp-${key}`);
165
- throw APIError.from("BAD_REQUEST", TWO_FACTOR_ERROR_CODES.OTP_HAS_EXPIRED);
166
- }
161
+ const consumed = await ctx.context.internalAdapter.consumeVerificationValue(`2fa-otp-${key}`);
162
+ if (!consumed) throw APIError.from("BAD_REQUEST", TWO_FACTOR_ERROR_CODES.OTP_HAS_EXPIRED);
163
+ const [otp, counter] = consumed.value?.split(":") ?? [];
167
164
  const allowedAttempts = options?.allowedAttempts || 5;
168
- if (parseInt(counter) >= allowedAttempts) {
169
- await ctx.context.internalAdapter.deleteVerificationByIdentifier(`2fa-otp-${key}`);
170
- throw APIError.from("BAD_REQUEST", TWO_FACTOR_ERROR_CODES.TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE);
171
- }
165
+ const attempts = parseInt(counter, 10) || 0;
166
+ if (attempts >= allowedAttempts) throw APIError.from("BAD_REQUEST", TWO_FACTOR_ERROR_CODES.TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE);
172
167
  const [storedValue, inputValue] = await decryptOrHashForComparison(ctx, otp, ctx.body.code);
173
168
  if (constantTimeEqual(new TextEncoder().encode(storedValue), new TextEncoder().encode(inputValue))) {
174
169
  if (!session.user.twoFactorEnabled) {
@@ -186,10 +181,13 @@ const otp2fa = (options) => {
186
181
  });
187
182
  }
188
183
  return valid(ctx);
189
- } else {
190
- await ctx.context.internalAdapter.updateVerificationByIdentifier(`2fa-otp-${key}`, { value: `${otp}:${(parseInt(counter, 10) || 0) + 1}` });
191
- return invalid("INVALID_CODE");
192
184
  }
185
+ await ctx.context.internalAdapter.createVerificationValue({
186
+ value: `${otp}:${attempts + 1}`,
187
+ identifier: `2fa-otp-${key}`,
188
+ expiresAt: consumed.expiresAt
189
+ });
190
+ return invalid("INVALID_CODE");
193
191
  })
194
192
  }
195
193
  };
@@ -28,7 +28,7 @@ const totp2fa = (options) => {
28
28
  id: "totp",
29
29
  version: PACKAGE_VERSION,
30
30
  endpoints: {
31
- generateTOTP: createAuthEndpoint({
31
+ generateTOTP: createAuthEndpoint.serverOnly({
32
32
  method: "POST",
33
33
  body: generateTOTPBodySchema,
34
34
  metadata: { openapi: {
@@ -23,12 +23,16 @@ async function verifyTwoFactor(ctx) {
23
23
  const dontRememberMe = await ctx.getSignedCookie(ctx.context.authCookies.dontRememberToken.name, ctx.context.secret);
24
24
  return {
25
25
  valid: async (ctx) => {
26
- const session = await ctx.context.internalAdapter.createSession(verificationToken.value, !!dontRememberMe);
26
+ const consumed = await ctx.context.internalAdapter.consumeVerificationValue(signedTwoFactorCookie);
27
+ if (!consumed || consumed.value !== user.id) {
28
+ expireCookie(ctx, twoFactorCookie);
29
+ throw APIError.from("UNAUTHORIZED", TWO_FACTOR_ERROR_CODES.INVALID_TWO_FACTOR_COOKIE);
30
+ }
31
+ const session = await ctx.context.internalAdapter.createSession(consumed.value, !!dontRememberMe);
27
32
  if (!session) throw APIError.from("INTERNAL_SERVER_ERROR", {
28
33
  message: "failed to create session",
29
34
  code: "FAILED_TO_CREATE_SESSION"
30
35
  });
31
- await ctx.context.internalAdapter.deleteVerificationByIdentifier(signedTwoFactorCookie);
32
36
  await setSessionCookie(ctx, {
33
37
  session,
34
38
  user