better-auth 1.6.11 → 1.6.13

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 (84) hide show
  1. package/dist/api/index.d.mts +12 -48
  2. package/dist/api/routes/account.d.mts +2 -23
  3. package/dist/api/routes/account.mjs +94 -73
  4. package/dist/api/routes/callback.d.mts +1 -1
  5. package/dist/api/routes/callback.mjs +39 -42
  6. package/dist/api/routes/email-verification.d.mts +1 -0
  7. package/dist/api/routes/email-verification.mjs +4 -3
  8. package/dist/api/routes/password.mjs +1 -1
  9. package/dist/api/routes/session.mjs +15 -10
  10. package/dist/api/routes/sign-in.d.mts +1 -0
  11. package/dist/api/routes/sign-in.mjs +3 -2
  12. package/dist/api/routes/sign-up.d.mts +1 -0
  13. package/dist/api/routes/sign-up.mjs +9 -7
  14. package/dist/api/routes/update-user.mjs +7 -7
  15. package/dist/client/fetch-plugins.mjs +2 -1
  16. package/dist/client/parser.mjs +0 -1
  17. package/dist/client/plugins/index.d.mts +3 -3
  18. package/dist/client/proxy.mjs +2 -1
  19. package/dist/context/create-context.mjs +10 -14
  20. package/dist/context/helpers.mjs +3 -2
  21. package/dist/cookies/cookie-utils.d.mts +24 -1
  22. package/dist/cookies/cookie-utils.mjs +85 -22
  23. package/dist/cookies/index.d.mts +2 -3
  24. package/dist/cookies/index.mjs +39 -11
  25. package/dist/cookies/session-store.mjs +4 -23
  26. package/dist/db/get-migration.mjs +4 -4
  27. package/dist/db/index.d.mts +2 -2
  28. package/dist/db/index.mjs +3 -2
  29. package/dist/db/internal-adapter.mjs +56 -50
  30. package/dist/db/schema.d.mts +15 -2
  31. package/dist/db/schema.mjs +26 -1
  32. package/dist/index.d.mts +2 -2
  33. package/dist/index.mjs +2 -2
  34. package/dist/oauth2/errors.mjs +16 -1
  35. package/dist/oauth2/index.d.mts +2 -2
  36. package/dist/oauth2/index.mjs +3 -3
  37. package/dist/oauth2/link-account.d.mts +27 -1
  38. package/dist/oauth2/link-account.mjs +27 -4
  39. package/dist/oauth2/state.mjs +8 -2
  40. package/dist/package.mjs +1 -1
  41. package/dist/plugins/access/access.mjs +11 -6
  42. package/dist/plugins/admin/admin.mjs +0 -4
  43. package/dist/plugins/admin/client.d.mts +1 -1
  44. package/dist/plugins/admin/routes.mjs +3 -3
  45. package/dist/plugins/anonymous/index.mjs +2 -2
  46. package/dist/plugins/bearer/index.mjs +4 -9
  47. package/dist/plugins/captcha/index.mjs +2 -2
  48. package/dist/plugins/email-otp/routes.mjs +1 -1
  49. package/dist/plugins/generic-oauth/index.d.mts +1 -1
  50. package/dist/plugins/generic-oauth/index.mjs +6 -6
  51. package/dist/plugins/generic-oauth/routes.mjs +37 -34
  52. package/dist/plugins/generic-oauth/types.d.mts +7 -0
  53. package/dist/plugins/last-login-method/client.mjs +2 -2
  54. package/dist/plugins/magic-link/index.mjs +0 -1
  55. package/dist/plugins/mcp/index.mjs +2 -5
  56. package/dist/plugins/multi-session/index.mjs +2 -2
  57. package/dist/plugins/oauth-proxy/index.mjs +45 -32
  58. package/dist/plugins/oauth-proxy/utils.mjs +3 -10
  59. package/dist/plugins/oidc-provider/index.mjs +2 -5
  60. package/dist/plugins/one-tap/client.mjs +9 -2
  61. package/dist/plugins/one-tap/index.mjs +16 -39
  62. package/dist/plugins/open-api/generator.mjs +16 -5
  63. package/dist/plugins/organization/adapter.mjs +61 -56
  64. package/dist/plugins/organization/client.d.mts +2 -1
  65. package/dist/plugins/organization/error-codes.d.mts +1 -0
  66. package/dist/plugins/organization/error-codes.mjs +2 -1
  67. package/dist/plugins/organization/routes/crud-invites.mjs +3 -0
  68. package/dist/plugins/organization/routes/crud-org.d.mts +4 -4
  69. package/dist/plugins/organization/routes/crud-org.mjs +2 -2
  70. package/dist/plugins/organization/types.d.mts +3 -3
  71. package/dist/plugins/phone-number/routes.mjs +1 -1
  72. package/dist/plugins/two-factor/backup-codes/index.d.mts +4 -3
  73. package/dist/plugins/two-factor/client.mjs +2 -1
  74. package/dist/plugins/two-factor/index.mjs +3 -2
  75. package/dist/plugins/username/index.d.mts +24 -2
  76. package/dist/plugins/username/index.mjs +49 -3
  77. package/dist/state.d.mts +2 -2
  78. package/dist/state.mjs +18 -4
  79. package/dist/test-utils/headers.mjs +2 -7
  80. package/dist/test-utils/test-instance.d.mts +36 -144
  81. package/dist/utils/index.d.mts +1 -1
  82. package/dist/utils/url.d.mts +2 -1
  83. package/dist/utils/url.mjs +9 -3
  84. package/package.json +15 -14
@@ -1,6 +1,7 @@
1
1
  import { db_exports } from "../../db/index.mjs";
2
2
  import { getEndpoints } from "../../api/index.mjs";
3
3
  import * as z from "zod";
4
+ import { toPascalCase } from "@better-auth/core/utils/string";
4
5
  //#region src/plugins/open-api/generator.ts
5
6
  const allowedType = new Set([
6
7
  "string",
@@ -156,7 +157,7 @@ function getResponse(responses) {
156
157
  } } },
157
158
  description: "Internal Server Error. This is a problem with the server that you cannot fix."
158
159
  },
159
- ...responses
160
+ ...responses ? structuredClone(responses) : {}
160
161
  };
161
162
  }
162
163
  function toOpenApiPath(path) {
@@ -196,6 +197,16 @@ async function generator(ctx, options) {
196
197
  return acc;
197
198
  }, {}) } };
198
199
  const paths = {};
200
+ const seenOperationIds = /* @__PURE__ */ new Set();
201
+ const uniqueOperationId = (operationId, method) => {
202
+ if (!operationId) return void 0;
203
+ const base = seenOperationIds.has(operationId) ? `${operationId}${toPascalCase(method)}` : operationId;
204
+ let result = base;
205
+ let n = 2;
206
+ while (seenOperationIds.has(result)) result = `${base}${n++}`;
207
+ seenOperationIds.add(result);
208
+ return result;
209
+ };
199
210
  Object.entries(baseEndpoints.api).forEach(([_, value]) => {
200
211
  if (!value.path || ctx.options.disabledPaths?.includes(value.path)) return;
201
212
  const options = value.options;
@@ -207,7 +218,7 @@ async function generator(ctx, options) {
207
218
  [method.toLowerCase()]: {
208
219
  tags: ["Default", ...options.metadata?.openapi?.tags || []],
209
220
  description: options.metadata?.openapi?.description,
210
- operationId: options.metadata?.openapi?.operationId,
221
+ operationId: uniqueOperationId(options.metadata?.openapi?.operationId, method),
211
222
  security: [{ bearerAuth: [] }],
212
223
  parameters: getParameters(options),
213
224
  responses: getResponse(options.metadata?.openapi?.responses)
@@ -220,7 +231,7 @@ async function generator(ctx, options) {
220
231
  [method.toLowerCase()]: {
221
232
  tags: ["Default", ...options.metadata?.openapi?.tags || []],
222
233
  description: options.metadata?.openapi?.description,
223
- operationId: options.metadata?.openapi?.operationId,
234
+ operationId: uniqueOperationId(options.metadata?.openapi?.operationId, method),
224
235
  security: [{ bearerAuth: [] }],
225
236
  parameters: getParameters(options),
226
237
  ...body ? { requestBody: body } : { requestBody: { content: { "application/json": { schema: {
@@ -253,7 +264,7 @@ async function generator(ctx, options) {
253
264
  [method.toLowerCase()]: {
254
265
  tags: options.metadata?.openapi?.tags || [plugin.id.charAt(0).toUpperCase() + plugin.id.slice(1)],
255
266
  description: options.metadata?.openapi?.description,
256
- operationId: options.metadata?.openapi?.operationId,
267
+ operationId: uniqueOperationId(options.metadata?.openapi?.operationId, method),
257
268
  security: [{ bearerAuth: [] }],
258
269
  parameters: getParameters(options),
259
270
  responses: getResponse(options.metadata?.openapi?.responses)
@@ -264,7 +275,7 @@ async function generator(ctx, options) {
264
275
  [method.toLowerCase()]: {
265
276
  tags: options.metadata?.openapi?.tags || [plugin.id.charAt(0).toUpperCase() + plugin.id.slice(1)],
266
277
  description: options.metadata?.openapi?.description,
267
- operationId: options.metadata?.openapi?.operationId,
278
+ operationId: uniqueOperationId(options.metadata?.openapi?.operationId, method),
268
279
  security: [{ bearerAuth: [] }],
269
280
  parameters: getParameters(options),
270
281
  requestBody: getRequestBody(options),
@@ -1,6 +1,6 @@
1
1
  import { getDate } from "../../utils/date.mjs";
2
2
  import { parseJSON } from "../../client/parser.mjs";
3
- import { getCurrentAdapter } from "@better-auth/core/context";
3
+ 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
@@ -184,46 +184,49 @@ const getOrgAdapter = (context, options) => {
184
184
  });
185
185
  },
186
186
  deleteMember: async ({ memberId, organizationId, userId: _userId }) => {
187
- const adapter = await getCurrentAdapter(baseAdapter);
188
- let userId;
189
- if (!_userId) {
190
- const member = await adapter.findOne({
187
+ return runWithTransaction(baseAdapter, async () => {
188
+ const adapter = await getCurrentAdapter(baseAdapter);
189
+ let userId;
190
+ if (!_userId) {
191
+ const member = await adapter.findOne({
192
+ model: "member",
193
+ where: [{
194
+ field: "id",
195
+ value: memberId
196
+ }]
197
+ });
198
+ if (!member) throw new BetterAuthError("Member not found");
199
+ userId = member.userId;
200
+ } else userId = _userId;
201
+ const member = await adapter.delete({
191
202
  model: "member",
192
203
  where: [{
193
204
  field: "id",
194
205
  value: memberId
195
206
  }]
196
207
  });
197
- if (!member) throw new BetterAuthError("Member not found");
198
- userId = member.userId;
199
- } else userId = _userId;
200
- const member = await adapter.delete({
201
- model: "member",
202
- where: [{
203
- field: "id",
204
- value: memberId
205
- }]
208
+ if (options?.teams?.enabled) {
209
+ const teams = await adapter.findMany({
210
+ model: "team",
211
+ where: [{
212
+ field: "organizationId",
213
+ value: organizationId
214
+ }]
215
+ });
216
+ if (teams.length > 0) await adapter.deleteMany({
217
+ model: "teamMember",
218
+ where: [{
219
+ field: "userId",
220
+ value: userId
221
+ }, {
222
+ field: "teamId",
223
+ value: teams.map((team) => team.id),
224
+ operator: "in"
225
+ }]
226
+ });
227
+ }
228
+ return member;
206
229
  });
207
- if (options?.teams?.enabled) {
208
- const teams = await adapter.findMany({
209
- model: "team",
210
- where: [{
211
- field: "organizationId",
212
- value: organizationId
213
- }]
214
- });
215
- await Promise.all(teams.map((team) => adapter.deleteMany({
216
- model: "teamMember",
217
- where: [{
218
- field: "teamId",
219
- value: team.id
220
- }, {
221
- field: "userId",
222
- value: userId
223
- }]
224
- })));
225
- }
226
- return member;
227
230
  },
228
231
  updateOrganization: async (organizationId, data) => {
229
232
  const organization = await (await getCurrentAdapter(baseAdapter)).update({
@@ -244,29 +247,31 @@ const getOrgAdapter = (context, options) => {
244
247
  }, orgAdditionalFields);
245
248
  },
246
249
  deleteOrganization: async (organizationId) => {
247
- const adapter = await getCurrentAdapter(baseAdapter);
248
- await adapter.deleteMany({
249
- model: "member",
250
- where: [{
251
- field: "organizationId",
252
- value: organizationId
253
- }]
254
- });
255
- await adapter.deleteMany({
256
- model: "invitation",
257
- where: [{
258
- field: "organizationId",
259
- value: organizationId
260
- }]
261
- });
262
- await adapter.delete({
263
- model: "organization",
264
- where: [{
265
- field: "id",
266
- value: organizationId
267
- }]
250
+ return runWithTransaction(baseAdapter, async () => {
251
+ const adapter = await getCurrentAdapter(baseAdapter);
252
+ await adapter.deleteMany({
253
+ model: "member",
254
+ where: [{
255
+ field: "organizationId",
256
+ value: organizationId
257
+ }]
258
+ });
259
+ await adapter.deleteMany({
260
+ model: "invitation",
261
+ where: [{
262
+ field: "organizationId",
263
+ value: organizationId
264
+ }]
265
+ });
266
+ await adapter.delete({
267
+ model: "organization",
268
+ where: [{
269
+ field: "id",
270
+ value: organizationId
271
+ }]
272
+ });
273
+ return organizationId;
268
274
  });
269
- return organizationId;
270
275
  },
271
276
  setActiveOrganization: async (sessionToken, organizationId, ctx) => {
272
277
  return await context.internalAdapter.updateSession(sessionToken, { activeOrganizationId: organizationId });
@@ -273,6 +273,7 @@ declare const organizationClient: <CO extends OrganizationClientOptions>(options
273
273
  ROLE_NAME_IS_ALREADY_TAKEN: _better_auth_core_utils_error_codes0.RawError<"ROLE_NAME_IS_ALREADY_TAKEN">;
274
274
  CANNOT_DELETE_A_PRE_DEFINED_ROLE: _better_auth_core_utils_error_codes0.RawError<"CANNOT_DELETE_A_PRE_DEFINED_ROLE">;
275
275
  ROLE_IS_ASSIGNED_TO_MEMBERS: _better_auth_core_utils_error_codes0.RawError<"ROLE_IS_ASSIGNED_TO_MEMBERS">;
276
+ INVALID_TEAM_ID: _better_auth_core_utils_error_codes0.RawError<"INVALID_TEAM_ID">;
276
277
  };
277
278
  };
278
279
  declare const inferOrgAdditionalFields: <O extends {
@@ -371,4 +372,4 @@ declare const inferOrgAdditionalFields: <O extends {
371
372
  additionalFields: unknown;
372
373
  } ? K : never]: S_1[K] } : undefined : undefined : undefined : S;
373
374
  //#endregion
374
- export { clientSideHasPermission, inferOrgAdditionalFields, organizationClient };
375
+ export { OrganizationClientOptions, clientSideHasPermission, inferOrgAdditionalFields, organizationClient };
@@ -60,6 +60,7 @@ declare const ORGANIZATION_ERROR_CODES: {
60
60
  ROLE_NAME_IS_ALREADY_TAKEN: _better_auth_core_utils_error_codes0.RawError<"ROLE_NAME_IS_ALREADY_TAKEN">;
61
61
  CANNOT_DELETE_A_PRE_DEFINED_ROLE: _better_auth_core_utils_error_codes0.RawError<"CANNOT_DELETE_A_PRE_DEFINED_ROLE">;
62
62
  ROLE_IS_ASSIGNED_TO_MEMBERS: _better_auth_core_utils_error_codes0.RawError<"ROLE_IS_ASSIGNED_TO_MEMBERS">;
63
+ INVALID_TEAM_ID: _better_auth_core_utils_error_codes0.RawError<"INVALID_TEAM_ID">;
63
64
  };
64
65
  //#endregion
65
66
  export { ORGANIZATION_ERROR_CODES };
@@ -58,7 +58,8 @@ const ORGANIZATION_ERROR_CODES = defineErrorCodes({
58
58
  INVALID_RESOURCE: "The provided permission includes an invalid resource",
59
59
  ROLE_NAME_IS_ALREADY_TAKEN: "That role name is already taken",
60
60
  CANNOT_DELETE_A_PRE_DEFINED_ROLE: "Cannot delete a pre-defined role",
61
- ROLE_IS_ASSIGNED_TO_MEMBERS: "Cannot delete a role that is assigned to members. Please reassign the members to a different role first"
61
+ ROLE_IS_ASSIGNED_TO_MEMBERS: "Cannot delete a role that is assigned to members. Please reassign the members to a different role first",
62
+ INVALID_TEAM_ID: "Team id contains a reserved character"
62
63
  });
63
64
  //#endregion
64
65
  export { ORGANIZATION_ERROR_CODES };
@@ -155,6 +155,9 @@ const createInvitation = (option) => {
155
155
  member
156
156
  }, ctx.context) : ctx.context.orgOptions.invitationLimit ?? 100;
157
157
  if ((await adapter.findPendingInvitations({ organizationId })).length >= invitationLimit) throw APIError.from("FORBIDDEN", ORGANIZATION_ERROR_CODES.INVITATION_LIMIT_REACHED);
158
+ if (ctx.context.orgOptions.teams?.enabled && "teamId" in ctx.body && ctx.body.teamId) {
159
+ if ((typeof ctx.body.teamId === "string" ? [ctx.body.teamId] : ctx.body.teamId).some((id) => id.includes(","))) throw APIError.from("BAD_REQUEST", ORGANIZATION_ERROR_CODES.INVALID_TEAM_ID);
160
+ }
158
161
  if (ctx.context.orgOptions.teams && ctx.context.orgOptions.teams.enabled && typeof ctx.context.orgOptions.teams.maximumMembersPerTeam !== "undefined" && "teamId" in ctx.body && ctx.body.teamId) {
159
162
  const teamIds = typeof ctx.body.teamId === "string" ? [ctx.body.teamId] : ctx.body.teamId;
160
163
  for (const teamId of teamIds) {
@@ -15,7 +15,7 @@ declare const createOrganization: <O extends OrganizationOptions>(options?: O |
15
15
  name: z.ZodString;
16
16
  slug: z.ZodString;
17
17
  userId: z.ZodOptional<z.ZodCoercedString<unknown>>;
18
- logo: z.ZodOptional<z.ZodString>;
18
+ logo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
19
19
  metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodAny>>;
20
20
  keepCurrentActiveOrganization: z.ZodOptional<z.ZodBoolean>;
21
21
  }, z.core.$strip>;
@@ -55,7 +55,7 @@ declare const createOrganization: <O extends OrganizationOptions>(options?: O |
55
55
  name: string;
56
56
  slug: string;
57
57
  userId?: string | undefined;
58
- logo?: string | undefined;
58
+ logo?: string | null | undefined;
59
59
  metadata?: Record<string, any> | undefined;
60
60
  keepCurrentActiveOrganization?: boolean | undefined;
61
61
  };
@@ -165,7 +165,7 @@ declare const updateOrganization: <O extends OrganizationOptions>(options?: O |
165
165
  data: z.ZodObject<{
166
166
  name: z.ZodOptional<z.ZodOptional<z.ZodString>>;
167
167
  slug: z.ZodOptional<z.ZodOptional<z.ZodString>>;
168
- logo: z.ZodOptional<z.ZodOptional<z.ZodString>>;
168
+ logo: z.ZodOptional<z.ZodOptional<z.ZodNullable<z.ZodString>>>;
169
169
  metadata: z.ZodOptional<z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodAny>>>;
170
170
  }, z.core.$strip>;
171
171
  organizationId: z.ZodOptional<z.ZodString>;
@@ -207,7 +207,7 @@ declare const updateOrganization: <O extends OrganizationOptions>(options?: O |
207
207
  data: {
208
208
  name?: string | undefined;
209
209
  slug?: string | undefined;
210
- logo?: string | undefined;
210
+ logo?: string | null | undefined;
211
211
  metadata?: Record<string, any> | undefined;
212
212
  } & Partial<InferAdditionalFieldsFromPluginOptions<"organization", O>>;
213
213
  organizationId?: string | undefined;
@@ -13,7 +13,7 @@ const baseOrganizationSchema = z.object({
13
13
  name: z.string().min(1).meta({ description: "The name of the organization" }),
14
14
  slug: z.string().min(1).meta({ description: "The slug of the organization" }),
15
15
  userId: z.coerce.string().meta({ description: "The user id of the organization creator. If not provided, the current user will be used. Should only be used by admins or when called by the server. server-only. Eg: \"user-id\"" }).optional(),
16
- logo: z.string().meta({ description: "The logo of the organization" }).optional(),
16
+ logo: z.string().meta({ description: "The logo of the organization" }).nullish(),
17
17
  metadata: z.record(z.string(), z.any()).meta({ description: "The metadata of the organization" }).optional(),
18
18
  keepCurrentActiveOrganization: z.boolean().meta({ description: "Whether to keep the current active organization active after creating a new one. Eg: true" }).optional()
19
19
  });
@@ -160,7 +160,7 @@ const checkOrganizationSlug = (options) => createAuthEndpoint("/organization/che
160
160
  const baseUpdateOrganizationSchema = z.object({
161
161
  name: z.string().min(1).meta({ description: "The name of the organization" }).optional(),
162
162
  slug: z.string().min(1).meta({ description: "The slug of the organization" }).optional(),
163
- logo: z.string().meta({ description: "The logo of the organization" }).optional(),
163
+ logo: z.string().meta({ description: "The logo of the organization" }).nullish(),
164
164
  metadata: z.record(z.string(), z.any()).meta({ description: "The metadata of the organization" }).optional()
165
165
  });
166
166
  const updateOrganization = (options) => {
@@ -317,7 +317,7 @@ interface OrganizationOptions {
317
317
  organization: {
318
318
  name?: string;
319
319
  slug?: string;
320
- logo?: string;
320
+ logo?: string | null;
321
321
  metadata?: Record<string, any>;
322
322
  [key: string]: any;
323
323
  };
@@ -348,7 +348,7 @@ interface OrganizationOptions {
348
348
  organization: {
349
349
  name?: string;
350
350
  slug?: string;
351
- logo?: string;
351
+ logo?: string | null;
352
352
  metadata?: Record<string, any>;
353
353
  [key: string]: any;
354
354
  };
@@ -358,7 +358,7 @@ interface OrganizationOptions {
358
358
  data: {
359
359
  name?: string;
360
360
  slug?: string;
361
- logo?: string;
361
+ logo?: string | null;
362
362
  metadata?: Record<string, any>;
363
363
  [key: string]: any;
364
364
  };
@@ -470,7 +470,7 @@ const resetPasswordPhoneNumber = (opts) => createAuthEndpoint("/phone-number/res
470
470
  else await ctx.context.internalAdapter.updatePassword(user.id, hashedPassword);
471
471
  await ctx.context.internalAdapter.deleteVerificationByIdentifier(phoneResetIdentifier);
472
472
  if (ctx.context.options.emailAndPassword?.onPasswordReset) await ctx.context.options.emailAndPassword.onPasswordReset({ user }, ctx.request);
473
- if (ctx.context.options.emailAndPassword?.revokeSessionsOnPasswordReset) await ctx.context.internalAdapter.deleteSessions(user.id);
473
+ if (ctx.context.options.emailAndPassword?.revokeSessionsOnPasswordReset) await ctx.context.internalAdapter.deleteUserSessions(user.id);
474
474
  return ctx.json({ status: true });
475
475
  });
476
476
  function generateOTP(size) {
@@ -264,9 +264,10 @@ declare const backupCode2fa: (opts: BackupCodeOptions) => {
264
264
  backupCodes: string[];
265
265
  }>;
266
266
  /**
267
- * ### Endpoint
268
- *
269
- * POST `/two-factor/view-backup-codes`
267
+ * A server-only function that returns a user's decrypted two-factor
268
+ * backup codes. It is not exposed over HTTP and has no client method;
269
+ * call it from trusted server code with a `userId` taken from an
270
+ * authenticated session.
270
271
  *
271
272
  * ### API Methods
272
273
  *
@@ -1,5 +1,6 @@
1
1
  import { PACKAGE_VERSION } from "../../version.mjs";
2
2
  import { TWO_FACTOR_ERROR_CODES } from "./error-code.mjs";
3
+ import { isSafeUrlScheme } from "@better-auth/core/utils/url";
3
4
  //#region src/plugins/two-factor/client.ts
4
5
  const twoFactorClient = (options) => {
5
6
  return {
@@ -29,7 +30,7 @@ const twoFactorClient = (options) => {
29
30
  await options.onTwoFactorRedirect({ twoFactorMethods: context.data.twoFactorMethods });
30
31
  return;
31
32
  }
32
- if (options?.twoFactorPage && typeof window !== "undefined") window.location.href = options.twoFactorPage;
33
+ if (options?.twoFactorPage && typeof window !== "undefined" && isSafeUrlScheme(options.twoFactorPage)) window.location.href = options.twoFactorPage;
33
34
  }
34
35
  } }
35
36
  }],
@@ -2,7 +2,7 @@ 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 { sessionMiddleware } from "../../api/routes/session.mjs";
5
+ import { sensitiveSessionMiddleware, sessionMiddleware } from "../../api/routes/session.mjs";
6
6
  import { shouldRequirePassword, validatePassword } from "../../utils/password.mjs";
7
7
  import { PACKAGE_VERSION } from "../../version.mjs";
8
8
  import { TWO_FACTOR_ERROR_CODES } from "./error-code.mjs";
@@ -138,7 +138,7 @@ const twoFactor = (options) => {
138
138
  disableTwoFactor: createAuthEndpoint("/two-factor/disable", {
139
139
  method: "POST",
140
140
  body: disableTwoFactorBodySchema,
141
- use: [sessionMiddleware],
141
+ use: [sensitiveSessionMiddleware],
142
142
  metadata: { openapi: {
143
143
  summary: "Disable two factor authentication",
144
144
  description: "Use this endpoint to disable two factor authentication.",
@@ -224,6 +224,7 @@ const twoFactor = (options) => {
224
224
  */
225
225
  deleteSessionCookie(ctx, true);
226
226
  await ctx.context.internalAdapter.deleteSession(data.session.token);
227
+ ctx.context.setNewSession(null);
227
228
  const maxAge = options?.twoFactorCookieMaxAge ?? 600;
228
229
  const twoFactorCookie = ctx.context.createAuthCookie(TWO_FACTOR_COOKIE_NAME, { maxAge });
229
230
  const identifier = `2fa-${generateRandomString(20)}`;
@@ -90,9 +90,20 @@ declare const username: (options?: UsernameOptions | undefined) => {
90
90
  name: string;
91
91
  image?: string | null | undefined;
92
92
  } & Record<string, unknown>, context: _better_auth_core0.GenericEndpointContext | null): Promise<{
93
+ data: {
94
+ username: string;
95
+ displayUsername: string;
96
+ id: string;
97
+ createdAt: Date;
98
+ updatedAt: Date;
99
+ email: string;
100
+ emailVerified: boolean;
101
+ name: string;
102
+ image?: string | null | undefined;
103
+ };
104
+ } | {
93
105
  data: {
94
106
  displayUsername?: string | undefined;
95
- username?: string | undefined;
96
107
  id: string;
97
108
  createdAt: Date;
98
109
  updatedAt: Date;
@@ -115,7 +126,18 @@ declare const username: (options?: UsernameOptions | undefined) => {
115
126
  }> & Record<string, unknown>, context: _better_auth_core0.GenericEndpointContext | null): Promise<{
116
127
  data: {
117
128
  displayUsername?: string | undefined;
118
- username?: string | undefined;
129
+ username: string;
130
+ id?: string | undefined;
131
+ createdAt?: Date | undefined;
132
+ updatedAt?: Date | undefined;
133
+ email?: string | undefined;
134
+ emailVerified?: boolean | undefined;
135
+ name?: string | undefined;
136
+ image?: string | null | undefined;
137
+ };
138
+ } | {
139
+ data: {
140
+ displayUsername?: string | undefined;
119
141
  id?: string | undefined;
120
142
  createdAt?: Date | undefined;
121
143
  updatedAt?: Date | undefined;
@@ -28,6 +28,31 @@ const username = (options) => {
28
28
  const displayUsernameNormalizer = (displayUsername) => {
29
29
  return options?.displayUsernameNormalization ? options.displayUsernameNormalization(displayUsername) : displayUsername;
30
30
  };
31
+ const minUsernameLength = options?.minUsernameLength || 3;
32
+ const maxUsernameLength = options?.maxUsernameLength || 30;
33
+ const validator = options?.usernameValidator || defaultUsernameValidator;
34
+ const pathsWithHttpHookValidation = ["/sign-up/email", "/update-user"];
35
+ async function validateUsername(username, displayUsername, adapter, currentUserId) {
36
+ const usernameToValidate = options?.validationOrder?.username === "post-normalization" ? normalizer(username) : username;
37
+ if (usernameToValidate.length < minUsernameLength) throw APIError.from("BAD_REQUEST", USERNAME_ERROR_CODES.USERNAME_TOO_SHORT);
38
+ if (usernameToValidate.length > maxUsernameLength) throw APIError.from("BAD_REQUEST", USERNAME_ERROR_CODES.USERNAME_TOO_LONG);
39
+ if (!await validator(usernameToValidate)) throw APIError.from("BAD_REQUEST", USERNAME_ERROR_CODES.INVALID_USERNAME);
40
+ const normalizedUsername = normalizer(username);
41
+ const existingUser = await adapter.findOne({
42
+ model: "user",
43
+ where: [{
44
+ field: "username",
45
+ value: normalizedUsername
46
+ }]
47
+ });
48
+ if (existingUser) {
49
+ if (!currentUserId || existingUser.id !== currentUserId) throw APIError.from("BAD_REQUEST", USERNAME_ERROR_CODES.USERNAME_IS_ALREADY_TAKEN);
50
+ }
51
+ if (displayUsername && options?.displayUsernameValidator) {
52
+ const displayUsernameToValidate = options?.validationOrder?.displayUsername === "post-normalization" ? displayUsernameNormalizer(displayUsername) : displayUsername;
53
+ if (!await options.displayUsernameValidator(displayUsernameToValidate)) throw APIError.from("BAD_REQUEST", USERNAME_ERROR_CODES.INVALID_DISPLAY_USERNAME);
54
+ }
55
+ }
31
56
  return {
32
57
  id: "username",
33
58
  version: PACKAGE_VERSION,
@@ -36,18 +61,39 @@ const username = (options) => {
36
61
  create: { async before(user, context) {
37
62
  const username = "username" in user ? user.username : null;
38
63
  const displayUsername = "displayUsername" in user ? user.displayUsername : null;
64
+ const currentPath = context?.path;
65
+ const skipValidation = currentPath && pathsWithHttpHookValidation.includes(currentPath);
66
+ if (username) {
67
+ if (!skipValidation) await validateUsername(username, displayUsername, ctx.adapter);
68
+ return { data: {
69
+ ...user,
70
+ username: normalizer(username),
71
+ displayUsername: displayUsername ? displayUsernameNormalizer(displayUsername) : username
72
+ } };
73
+ }
39
74
  return { data: {
40
75
  ...user,
41
- ...username ? { username: normalizer(username) } : {},
42
76
  ...displayUsername ? { displayUsername: displayUsernameNormalizer(displayUsername) } : {}
43
77
  } };
44
78
  } },
45
79
  update: { async before(user, context) {
46
80
  const username = "username" in user ? user.username : null;
47
81
  const displayUsername = "displayUsername" in user ? user.displayUsername : null;
82
+ const currentPath = context?.path;
83
+ const skipValidation = currentPath && pathsWithHttpHookValidation.includes(currentPath);
84
+ if (username) {
85
+ if (!skipValidation) {
86
+ const currentUserId = context?.context?.session?.user?.id || ("id" in user ? user.id : null);
87
+ await validateUsername(username, displayUsername, ctx.adapter, currentUserId);
88
+ }
89
+ return { data: {
90
+ ...user,
91
+ username: normalizer(username),
92
+ ...displayUsername ? { displayUsername: displayUsernameNormalizer(displayUsername) } : {}
93
+ } };
94
+ }
48
95
  return { data: {
49
96
  ...user,
50
- ...username ? { username: normalizer(username) } : {},
51
97
  ...displayUsername ? { displayUsername: displayUsernameNormalizer(displayUsername) } : {}
52
98
  } };
53
99
  } }
@@ -153,7 +199,7 @@ const username = (options) => {
153
199
  if (!ctx.context.options?.emailVerification?.sendVerificationEmail) throw APIError.from("FORBIDDEN", USERNAME_ERROR_CODES.EMAIL_NOT_VERIFIED);
154
200
  if (ctx.context.options?.emailVerification?.sendOnSignIn) {
155
201
  const token = await createEmailVerificationToken(ctx.context.secret, user.email, void 0, ctx.context.options.emailVerification?.expiresIn);
156
- const url = `${ctx.context.baseURL}/verify-email?token=${token}&callbackURL=${ctx.body.callbackURL || "/"}`;
202
+ const url = `${ctx.context.baseURL}/verify-email?token=${token}&callbackURL=${encodeURIComponent(ctx.body.callbackURL || "/")}`;
157
203
  await ctx.context.runInBackgroundOrAwait(ctx.context.options.emailVerification.sendVerificationEmail({
158
204
  user,
159
205
  url,
package/dist/state.d.mts CHANGED
@@ -27,8 +27,8 @@ declare function generateGenericState(c: GenericEndpointContext, stateData: Stat
27
27
  state: string;
28
28
  codeVerifier: string;
29
29
  }>;
30
- declare function parseGenericState(c: GenericEndpointContext, state: string, settings?: {
31
- cookieName: string;
30
+ declare function parseGenericState(c: GenericEndpointContext, state: string | undefined, settings?: {
31
+ cookieName?: string;
32
32
  skipStateCookieCheck?: boolean;
33
33
  }): Promise<{
34
34
  [x: string]: unknown;
package/dist/state.mjs CHANGED
@@ -20,10 +20,19 @@ const stateDataSchema = z.looseObject({
20
20
  var StateError = class extends BetterAuthError {
21
21
  code;
22
22
  details;
23
+ /**
24
+ * The per-flow `errorCallbackURL` recovered from the parsed state, when the
25
+ * failure happened after the state was successfully parsed (for example a
26
+ * nonce or state-cookie mismatch). It was origin-validated at sign-in, so
27
+ * the callback can safely redirect there instead of the default error page.
28
+ * Absent when the state could not be parsed at all.
29
+ */
30
+ errorURL;
23
31
  constructor(message, options) {
24
32
  super(message, options);
25
33
  this.code = options.code;
26
34
  this.details = options.details;
35
+ this.errorURL = options.errorURL;
27
36
  }
28
37
  };
29
38
  async function generateGenericState(c, stateData, settings) {
@@ -62,6 +71,7 @@ async function generateGenericState(c, stateData, settings) {
62
71
  };
63
72
  }
64
73
  async function parseGenericState(c, state, settings) {
74
+ if (!state) throw new StateError("State not found in OAuth callback", { code: "state_not_found" });
65
75
  const storeStateStrategy = c.context.oauthConfig.storeStateStrategy;
66
76
  let parsedData;
67
77
  if (storeStateStrategy === "cookie") {
@@ -86,7 +96,8 @@ async function parseGenericState(c, state, settings) {
86
96
  }
87
97
  if (!parsedData.oauthState || parsedData.oauthState !== state) throw new StateError("State mismatch: OAuth state parameter does not match stored state", {
88
98
  code: "state_security_mismatch",
89
- details: { state }
99
+ details: { state },
100
+ errorURL: parsedData.errorURL
90
101
  });
91
102
  expireCookie(c, stateCookie);
92
103
  } else {
@@ -98,20 +109,23 @@ async function parseGenericState(c, state, settings) {
98
109
  parsedData = stateDataSchema.parse(JSON.parse(data.value));
99
110
  if (parsedData.oauthState !== void 0 && parsedData.oauthState !== state) throw new StateError("State mismatch: OAuth state parameter does not match stored state", {
100
111
  code: "state_security_mismatch",
101
- details: { state }
112
+ details: { state },
113
+ errorURL: parsedData.errorURL
102
114
  });
103
115
  const stateCookie = c.context.createAuthCookie(settings?.cookieName ?? "state");
104
116
  const stateCookieValue = await c.getSignedCookie(stateCookie.name, c.context.secret);
105
117
  if (!(settings?.skipStateCookieCheck ?? c.context.oauthConfig.skipStateCookieCheck) && (!stateCookieValue || stateCookieValue !== state)) throw new StateError("State mismatch: State not persisted correctly", {
106
118
  code: "state_security_mismatch",
107
- details: { state }
119
+ details: { state },
120
+ errorURL: parsedData.errorURL
108
121
  });
109
122
  expireCookie(c, stateCookie);
110
123
  await c.context.internalAdapter.deleteVerificationByIdentifier(state);
111
124
  }
112
125
  if (parsedData.expiresAt < Date.now()) throw new StateError("Invalid state: request expired", {
113
126
  code: "state_mismatch",
114
- details: { expiresAt: parsedData.expiresAt }
127
+ details: { expiresAt: parsedData.expiresAt },
128
+ errorURL: parsedData.errorURL
115
129
  });
116
130
  return parsedData;
117
131
  }
@@ -1,3 +1,4 @@
1
+ import { applySetCookies } from "../cookies/cookie-utils.mjs";
1
2
  //#region src/test-utils/headers.ts
2
3
  /**
3
4
  * converts set cookie containing headers to
@@ -9,13 +10,7 @@ function convertSetCookieToCookie(headers) {
9
10
  if (name.toLowerCase() === "set-cookie") setCookieHeaders.push(value);
10
11
  });
11
12
  if (setCookieHeaders.length === 0) return headers;
12
- const existingCookies = headers.get("cookie") || "";
13
- const cookies = existingCookies ? existingCookies.split("; ") : [];
14
- setCookieHeaders.forEach((setCookie) => {
15
- const cookiePair = setCookie.split(";")[0];
16
- cookies.push(cookiePair.trim());
17
- });
18
- headers.set("cookie", cookies.join("; "));
13
+ applySetCookies(headers, setCookieHeaders);
19
14
  return headers;
20
15
  }
21
16
  //#endregion