better-auth 1.6.12 → 1.6.14

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 (39) hide show
  1. package/dist/api/index.d.mts +4 -46
  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.mjs +3 -2
  5. package/dist/api/routes/password.mjs +1 -1
  6. package/dist/api/routes/session.mjs +1 -1
  7. package/dist/api/routes/sign-in.mjs +1 -1
  8. package/dist/api/routes/update-user.mjs +3 -3
  9. package/dist/client/fetch-plugins.mjs +2 -1
  10. package/dist/context/create-context.mjs +10 -14
  11. package/dist/cookies/index.mjs +1 -1
  12. package/dist/db/internal-adapter.mjs +19 -20
  13. package/dist/db/to-zod.d.mts +2 -2
  14. package/dist/db/to-zod.mjs +1 -1
  15. package/dist/oauth2/index.d.mts +2 -2
  16. package/dist/oauth2/index.mjs +3 -3
  17. package/dist/oauth2/link-account.d.mts +27 -1
  18. package/dist/oauth2/link-account.mjs +24 -1
  19. package/dist/package.mjs +1 -1
  20. package/dist/plugins/admin/routes.mjs +3 -3
  21. package/dist/plugins/anonymous/index.mjs +2 -2
  22. package/dist/plugins/email-otp/routes.mjs +1 -1
  23. package/dist/plugins/generic-oauth/routes.mjs +3 -2
  24. package/dist/plugins/mcp/index.mjs +2 -1
  25. package/dist/plugins/oauth-proxy/index.mjs +1 -1
  26. package/dist/plugins/oidc-provider/index.mjs +2 -1
  27. package/dist/plugins/one-tap/client.mjs +9 -2
  28. package/dist/plugins/one-tap/index.mjs +16 -39
  29. package/dist/plugins/organization/adapter.mjs +2 -0
  30. package/dist/plugins/organization/routes/crud-access-control.d.mts +1 -1
  31. package/dist/plugins/organization/routes/crud-invites.mjs +30 -4
  32. package/dist/plugins/organization/routes/crud-org.d.mts +4 -4
  33. package/dist/plugins/organization/routes/crud-org.mjs +2 -2
  34. package/dist/plugins/organization/types.d.mts +21 -14
  35. package/dist/plugins/phone-number/routes.mjs +1 -1
  36. package/dist/plugins/two-factor/backup-codes/index.d.mts +4 -3
  37. package/dist/plugins/two-factor/client.mjs +2 -1
  38. package/dist/test-utils/test-instance.d.mts +12 -138
  39. package/package.json +8 -8
@@ -46,6 +46,7 @@ async function handleOAuthUserInfo(c, opts) {
46
46
  };
47
47
  }
48
48
  if (userInfo.emailVerified && !dbUser.user.emailVerified && userInfo.email.toLowerCase() === dbUser.user.email) await c.context.internalAdapter.updateUser(dbUser.user.id, { emailVerified: true });
49
+ user = await applyUpdateUserInfoOnLink(c, dbUser.user.id, userInfo) ?? user;
49
50
  } else {
50
51
  const freshTokens = c.context.options.account?.updateAccountOnSignIn !== false ? Object.fromEntries(Object.entries({
51
52
  idToken: account.idToken,
@@ -137,5 +138,27 @@ async function handleOAuthUserInfo(c, opts) {
137
138
  isRegister
138
139
  };
139
140
  }
141
+ /**
142
+ * Apply the `account.accountLinking.updateUserInfoOnLink` policy: when enabled,
143
+ * copy the freshly linked provider's profile onto the local user, matching the
144
+ * field set persisted on sign-up. The local `email` and `emailVerified` are
145
+ * never changed, so a link can't rebind the account's identity, and
146
+ * `updateUser` drops `undefined` fields, so a provider that omits one leaves
147
+ * the existing column intact.
148
+ *
149
+ * Returns the updated user so a caller that issues a session can seed the
150
+ * cookie cache with the fresh row. Returns `undefined` when the policy is
151
+ * disabled or the update fails: a failed profile sync must not abort the link.
152
+ */
153
+ async function applyUpdateUserInfoOnLink(c, userId, userInfo) {
154
+ if (c.context.options.account?.accountLinking?.updateUserInfoOnLink !== true) return;
155
+ const { id: _id, email: _email, emailVerified: _emailVerified, ...profile } = userInfo;
156
+ try {
157
+ return await c.context.internalAdapter.updateUser(userId, profile);
158
+ } catch (e) {
159
+ c.context.logger.warn("Could not update user info on account link", e);
160
+ return;
161
+ }
162
+ }
140
163
  //#endregion
141
- export { handleOAuthUserInfo };
164
+ export { applyUpdateUserInfoOnLink, handleOAuthUserInfo };
package/dist/package.mjs CHANGED
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "1.6.12";
2
+ var version = "1.6.14";
3
3
  //#endregion
4
4
  export { version };
@@ -462,7 +462,7 @@ const banUser = (opts) => createAuthEndpoint("/admin/ban-user", {
462
462
  banExpires: ctx.body.banExpiresIn ? getDate(ctx.body.banExpiresIn, "sec") : opts?.defaultBanExpiresIn ? getDate(opts.defaultBanExpiresIn, "sec") : void 0,
463
463
  updatedAt: /* @__PURE__ */ new Date()
464
464
  });
465
- await ctx.context.internalAdapter.deleteSessions(ctx.body.userId);
465
+ await ctx.context.internalAdapter.deleteUserSessions(ctx.body.userId);
466
466
  return ctx.json({ user: parseUserOutput(ctx.context.options, user) });
467
467
  });
468
468
  const impersonateUserBodySchema = z.object({ userId: z.coerce.string().meta({ description: "The user id" }) });
@@ -658,7 +658,7 @@ const revokeUserSessions = (opts) => createAuthEndpoint("/admin/revoke-user-sess
658
658
  options: opts,
659
659
  permissions: { session: ["revoke"] }
660
660
  })) throw APIError.from("FORBIDDEN", ADMIN_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_REVOKE_USERS_SESSIONS);
661
- await ctx.context.internalAdapter.deleteSessions(ctx.body.userId);
661
+ await ctx.context.internalAdapter.deleteUserSessions(ctx.body.userId);
662
662
  return ctx.json({ success: true });
663
663
  });
664
664
  const removeUserBodySchema = z.object({ userId: z.coerce.string().meta({ description: "The user id" }) });
@@ -703,7 +703,7 @@ const removeUser = (opts) => createAuthEndpoint("/admin/remove-user", {
703
703
  })) throw APIError.from("FORBIDDEN", ADMIN_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_DELETE_USERS);
704
704
  if (ctx.body.userId === ctx.context.session.user.id) throw APIError.from("BAD_REQUEST", ADMIN_ERROR_CODES.YOU_CANNOT_REMOVE_YOURSELF);
705
705
  if (!await ctx.context.internalAdapter.findUserById(ctx.body.userId)) throw APIError.from("NOT_FOUND", BASE_ERROR_CODES.USER_NOT_FOUND);
706
- await ctx.context.internalAdapter.deleteSessions(ctx.body.userId);
706
+ await ctx.context.internalAdapter.deleteUserSessions(ctx.body.userId);
707
707
  await ctx.context.internalAdapter.deleteUser(ctx.body.userId);
708
708
  return ctx.json({ success: true });
709
709
  });
@@ -102,7 +102,7 @@ const anonymous = (options) => {
102
102
  if (options?.disableDeleteAnonymousUser) throw APIError.from("BAD_REQUEST", ANONYMOUS_ERROR_CODES.DELETE_ANONYMOUS_USER_DISABLED);
103
103
  if (!session.user.isAnonymous) throw APIError.from("FORBIDDEN", ANONYMOUS_ERROR_CODES.USER_IS_NOT_ANONYMOUS);
104
104
  try {
105
- await ctx.context.internalAdapter.deleteSessions(session.user.id);
105
+ await ctx.context.internalAdapter.deleteUserSessions(session.user.id);
106
106
  } catch (error) {
107
107
  ctx.context.logger.error("Failed to delete anonymous user sessions", error);
108
108
  throw APIError.from("INTERNAL_SERVER_ERROR", ANONYMOUS_ERROR_CODES.FAILED_TO_DELETE_ANONYMOUS_USER_SESSIONS);
@@ -154,7 +154,7 @@ const anonymous = (options) => {
154
154
  const newSessionIsAnonymous = Boolean(newSessionUser?.isAnonymous);
155
155
  if (options?.disableDeleteAnonymousUser || isSameUser || newSessionIsAnonymous) return;
156
156
  try {
157
- await ctx.context.internalAdapter.deleteSessions(session.user.id);
157
+ await ctx.context.internalAdapter.deleteUserSessions(session.user.id);
158
158
  await ctx.context.internalAdapter.deleteUser(session.user.id);
159
159
  } catch (error) {
160
160
  ctx.context.logger.error("Failed to clean up anonymous user during post-link cleanup", {
@@ -585,7 +585,7 @@ const resetPasswordEmailOTP = (opts) => createAuthEndpoint("/email-otp/reset-pas
585
585
  else await ctx.context.internalAdapter.updatePassword(user.user.id, passwordHash);
586
586
  if (ctx.context.options.emailAndPassword?.onPasswordReset) await ctx.context.options.emailAndPassword.onPasswordReset({ user: user.user }, ctx.request);
587
587
  if (!user.user.emailVerified) await ctx.context.internalAdapter.updateUser(user.user.id, { emailVerified: true });
588
- if (ctx.context.options.emailAndPassword?.revokeSessionsOnPasswordReset) await ctx.context.internalAdapter.deleteSessions(user.user.id);
588
+ if (ctx.context.options.emailAndPassword?.revokeSessionsOnPasswordReset) await ctx.context.internalAdapter.deleteUserSessions(user.user.id);
589
589
  return ctx.json({ success: true });
590
590
  });
591
591
  const requestEmailChangeEmailOTPBodySchema = z.object({
@@ -1,10 +1,10 @@
1
1
  import { isAPIError } from "../../utils/is-api-error.mjs";
2
2
  import { setSessionCookie } from "../../cookies/index.mjs";
3
3
  import { missingEmailLogMessage, redirectOnError } from "../../oauth2/errors.mjs";
4
- import { generateState, parseState } from "../../oauth2/state.mjs";
5
4
  import { setTokenUtil } from "../../oauth2/utils.mjs";
5
+ import { applyUpdateUserInfoOnLink, handleOAuthUserInfo } from "../../oauth2/link-account.mjs";
6
+ import { generateState, parseState } from "../../oauth2/state.mjs";
6
7
  import { sessionMiddleware } from "../../api/routes/session.mjs";
7
- import { handleOAuthUserInfo } from "../../oauth2/link-account.mjs";
8
8
  import { HIDE_METADATA } from "../../utils/hide-metadata.mjs";
9
9
  import { APIError as APIError$1 } from "../../api/index.mjs";
10
10
  import { GENERIC_OAUTH_ERROR_CODES } from "./error-codes.mjs";
@@ -248,6 +248,7 @@ const oAuth2Callback = (options) => createAuthEndpoint("/oauth2/callback/:provid
248
248
  refreshToken: await setTokenUtil(tokens.refreshToken, ctx.context),
249
249
  idToken: tokens.idToken
250
250
  })) redirectOnError(ctx, resolvedErrorURL, "unable_to_link_account");
251
+ await applyUpdateUserInfoOnLink(ctx, link.userId, userInfo);
251
252
  let toRedirectTo;
252
253
  try {
253
254
  toRedirectTo = callbackURL.toString();
@@ -14,6 +14,7 @@ import { oidcProvider } from "../oidc-provider/index.mjs";
14
14
  import { authorizeMCPOAuth } from "./authorize.mjs";
15
15
  import { isProduction, logger } from "@better-auth/core/env";
16
16
  import { safeJSONParse } from "@better-auth/core/utils/json";
17
+ import { isSafeUrlScheme } from "@better-auth/core/utils/url";
17
18
  import { createAuthEndpoint, createAuthMiddleware } from "@better-auth/core/api";
18
19
  import * as z from "zod";
19
20
  import { base64 } from "@better-auth/utils/base64";
@@ -86,7 +87,7 @@ const getMCPProtectedResourceMetadata = (ctx, options) => {
86
87
  };
87
88
  };
88
89
  const registerMcpClientBodySchema = z.object({
89
- redirect_uris: z.array(z.string()),
90
+ redirect_uris: z.array(z.string().refine(isSafeUrlScheme, { message: "redirect_uri cannot use a javascript:, data:, or vbscript: scheme" })),
90
91
  token_endpoint_auth_method: z.enum([
91
92
  "none",
92
93
  "client_secret_basic",
@@ -5,8 +5,8 @@ import { parseSetCookieHeader } from "../../cookies/cookie-utils.mjs";
5
5
  import { symmetricDecrypt, symmetricEncrypt } from "../../crypto/index.mjs";
6
6
  import { setSessionCookie } from "../../cookies/index.mjs";
7
7
  import { redirectOnError } from "../../oauth2/errors.mjs";
8
- import { parseGenericState } from "../../state.mjs";
9
8
  import { handleOAuthUserInfo } from "../../oauth2/link-account.mjs";
9
+ import { parseGenericState } from "../../state.mjs";
10
10
  import { PACKAGE_VERSION } from "../../version.mjs";
11
11
  import { parseJSON } from "../../client/parser.mjs";
12
12
  import { checkSkipProxy, resolveCurrentURL, stripTrailingSlash } from "./utils.mjs";
@@ -15,6 +15,7 @@ import { authorize } from "./authorize.mjs";
15
15
  import { schema } from "./schema.mjs";
16
16
  import { defaultClientSecretHasher } from "./utils.mjs";
17
17
  import { getCurrentAuthContext } from "@better-auth/core/context";
18
+ import { isSafeUrlScheme } from "@better-auth/core/utils/url";
18
19
  import { createAuthEndpoint, createAuthMiddleware } from "@better-auth/core/api";
19
20
  import { deprecate } from "@better-auth/core/utils/deprecate";
20
21
  import * as z from "zod";
@@ -101,7 +102,7 @@ const oAuthConsentBodySchema = z.object({
101
102
  });
102
103
  const oAuth2TokenBodySchema = z.record(z.any(), z.any());
103
104
  const registerOAuthApplicationBodySchema = z.object({
104
- redirect_uris: z.array(z.string()).meta({ description: "A list of redirect URIs. Eg: [\"https://client.example.com/callback\"]" }),
105
+ redirect_uris: z.array(z.string().refine(isSafeUrlScheme, { message: "redirect_uri cannot use a javascript:, data:, or vbscript: scheme" })).meta({ description: "A list of redirect URIs. Eg: [\"https://client.example.com/callback\"]" }),
105
106
  token_endpoint_auth_method: z.enum([
106
107
  "none",
107
108
  "client_secret_basic",
@@ -1,4 +1,5 @@
1
1
  import { PACKAGE_VERSION } from "../../version.mjs";
2
+ import { isSafeUrlScheme } from "@better-auth/core/utils/url";
2
3
  //#region src/plugins/one-tap/client.ts
3
4
  let isRequestInProgress = false;
4
5
  function isFedCMSupported() {
@@ -49,7 +50,10 @@ const oneTapClient = (options) => {
49
50
  ...opts?.fetchOptions,
50
51
  ...fetchOptions
51
52
  });
52
- if (!opts?.fetchOptions && !fetchOptions || opts?.callbackURL) window.location.href = opts?.callbackURL ?? "/";
53
+ if (!opts?.fetchOptions && !fetchOptions || opts?.callbackURL) {
54
+ const target = opts?.callbackURL ?? "/";
55
+ if (isSafeUrlScheme(target)) window.location.href = target;
56
+ }
53
57
  }
54
58
  const { autoSelect, cancelOnTapOutside, context } = opts ?? {};
55
59
  const contextValue = context ?? options.context ?? "signin";
@@ -82,7 +86,10 @@ const oneTapClient = (options) => {
82
86
  ...opts?.fetchOptions,
83
87
  ...fetchOptions
84
88
  });
85
- if (!opts?.fetchOptions && !fetchOptions || opts?.callbackURL) window.location.href = opts?.callbackURL ?? "/";
89
+ if (!opts?.fetchOptions && !fetchOptions || opts?.callbackURL) {
90
+ const target = opts?.callbackURL ?? "/";
91
+ if (isSafeUrlScheme(target)) window.location.href = target;
92
+ }
86
93
  }
87
94
  const { autoSelect, cancelOnTapOutside, context } = opts ?? {};
88
95
  const contextValue = context ?? options.context ?? "signin";
@@ -1,5 +1,6 @@
1
1
  import { parseUserOutput } from "../../db/schema.mjs";
2
2
  import { setSessionCookie } from "../../cookies/index.mjs";
3
+ import { handleOAuthUserInfo } from "../../oauth2/link-account.mjs";
3
4
  import { APIError } from "../../api/index.mjs";
4
5
  import { PACKAGE_VERSION } from "../../version.mjs";
5
6
  import { toBoolean } from "../../utils/boolean.mjs";
@@ -47,51 +48,27 @@ const oneTap = (options) => ({
47
48
  }
48
49
  const { email: rawEmail, email_verified, name, picture, sub } = payload;
49
50
  if (!rawEmail) return ctx.json({ error: "Email not available in token" });
50
- const email = rawEmail.toLowerCase();
51
- const user = await ctx.context.internalAdapter.findUserByEmail(email);
52
- if (!user) {
53
- if (options?.disableSignup) throw new APIError("BAD_GATEWAY", { message: "User not found" });
54
- const newUser = await ctx.context.internalAdapter.createOAuthUser({
55
- email,
51
+ const result = await handleOAuthUserInfo(ctx, {
52
+ userInfo: {
53
+ id: sub,
54
+ email: rawEmail.toLowerCase(),
56
55
  emailVerified: typeof email_verified === "boolean" ? email_verified : toBoolean(email_verified),
57
- name,
56
+ name: name ?? "",
58
57
  image: picture
59
- }, {
60
- providerId: "google",
61
- accountId: sub
62
- });
63
- if (!newUser) throw new APIError("INTERNAL_SERVER_ERROR", { message: "Could not create user" });
64
- const session = await ctx.context.internalAdapter.createSession(newUser.user.id);
65
- await setSessionCookie(ctx, {
66
- user: newUser.user,
67
- session
68
- });
69
- return ctx.json({
70
- token: session.token,
71
- user: parseUserOutput(ctx.context.options, newUser.user)
72
- });
73
- }
74
- if (!await ctx.context.internalAdapter.findAccount(sub)) {
75
- const accountLinking = ctx.context.options.account?.accountLinking;
76
- const providerEmailVerified = typeof email_verified === "boolean" ? email_verified : toBoolean(email_verified);
77
- const requireLocalEmailVerified = accountLinking?.requireLocalEmailVerified ?? true;
78
- if (accountLinking?.enabled !== false && accountLinking?.disableImplicitLinking !== true && (!requireLocalEmailVerified || user.user.emailVerified) && (ctx.context.trustedProviders.includes("google") || providerEmailVerified)) await ctx.context.internalAdapter.linkAccount({
79
- userId: user.user.id,
58
+ },
59
+ account: {
80
60
  providerId: "google",
81
61
  accountId: sub,
82
- scope: "openid,profile,email",
83
- idToken
84
- });
85
- else throw new APIError("UNAUTHORIZED", { message: "Google identity cannot be linked: implicit account-linking is disabled, the local email is not verified, or the Google email_verified claim is false and Google is not a trusted provider" });
86
- }
87
- const session = await ctx.context.internalAdapter.createSession(user.user.id);
88
- await setSessionCookie(ctx, {
89
- user: user.user,
90
- session
62
+ idToken,
63
+ scope: "openid,profile,email"
64
+ },
65
+ disableSignUp: options?.disableSignup
91
66
  });
67
+ if (result.error) throw new APIError("UNAUTHORIZED", { message: result.error });
68
+ await setSessionCookie(ctx, result.data);
92
69
  return ctx.json({
93
- token: session.token,
94
- user: parseUserOutput(ctx.context.options, user.user)
70
+ token: result.data.session.token,
71
+ user: parseUserOutput(ctx.context.options, result.data.user)
95
72
  });
96
73
  }) },
97
74
  options
@@ -550,9 +550,11 @@ const getOrgAdapter = (context, options) => {
550
550
  createInvitation: async ({ invitation, user }) => {
551
551
  const adapter = await getCurrentAdapter(baseAdapter);
552
552
  const expiresAt = getDate(options?.invitationExpiresIn || 3600 * 48, "sec");
553
+ const invitationId = context.generateId({ model: "invitation" });
553
554
  return await adapter.create({
554
555
  model: "invitation",
555
556
  data: {
557
+ ...invitationId !== false ? { id: invitationId } : {},
556
558
  status: "pending",
557
559
  expiresAt,
558
560
  createdAt: /* @__PURE__ */ new Date(),
@@ -14,7 +14,7 @@ declare const createOrgRole: <O extends OrganizationOptions>(options: O) => bett
14
14
  role: z.ZodString;
15
15
  permission: z.ZodRecord<z.ZodString, z.ZodArray<z.ZodString>>;
16
16
  additionalFields: z.ZodOptional<z.ZodObject<{
17
- [x: string]: z.ZodOptional<z.ZodAny>;
17
+ [x: string]: z.ZodAny;
18
18
  }, z.core.$strip>>;
19
19
  }, z.core.$strip>;
20
20
  metadata: {
@@ -19,6 +19,20 @@ const baseInvitationSchema = z.object({
19
19
  resend: z.boolean().meta({ description: "Resend the invitation email, if the user is already invited. Eg: true" }).optional(),
20
20
  teamId: z.union([z.string().meta({ description: "The team ID to invite the user to" }).optional(), z.array(z.string()).meta({ description: "The team IDs to invite the user to" }).optional()])
21
21
  });
22
+ const getAdvancedGenerateId = (advancedOptions) => {
23
+ if (typeof advancedOptions !== "object" || advancedOptions === null) return;
24
+ const generateId = advancedOptions.generateId;
25
+ if (typeof generateId !== "function") return;
26
+ return generateId;
27
+ };
28
+ const hasBuiltInOpaqueInvitationIdGeneration = ({ advancedGenerateId, databaseGenerateId }) => advancedGenerateId === void 0 && (databaseGenerateId === void 0 || databaseGenerateId === "uuid");
29
+ const shouldRequireVerifiedEmailForInvitationIdAction = ({ organizationOptions, advancedGenerateId, databaseGenerateId }) => {
30
+ if (organizationOptions.requireEmailVerificationOnInvitation !== void 0) return organizationOptions.requireEmailVerificationOnInvitation;
31
+ return !hasBuiltInOpaqueInvitationIdGeneration({
32
+ advancedGenerateId,
33
+ databaseGenerateId
34
+ });
35
+ };
22
36
  const createInvitation = (option) => {
23
37
  const additionalFieldsSchema = toZodSchema({
24
38
  fields: option?.schema?.invitation?.additionalFields || {},
@@ -247,7 +261,11 @@ const acceptInvitation = (options) => createAuthEndpoint("/organization/accept-i
247
261
  const invitation = await adapter.findInvitationById(ctx.body.invitationId);
248
262
  if (!invitation || invitation.expiresAt < /* @__PURE__ */ new Date() || invitation.status !== "pending") throw APIError.from("BAD_REQUEST", ORGANIZATION_ERROR_CODES.INVITATION_NOT_FOUND);
249
263
  if (invitation.email.toLowerCase() !== session.user.email.toLowerCase()) throw APIError.from("FORBIDDEN", ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION);
250
- if ((ctx.context.orgOptions.requireEmailVerificationOnInvitation ?? true) && !session.user.emailVerified) throw APIError.from("FORBIDDEN", ORGANIZATION_ERROR_CODES.EMAIL_VERIFICATION_REQUIRED_BEFORE_ACCEPTING_OR_REJECTING_INVITATION);
264
+ if (shouldRequireVerifiedEmailForInvitationIdAction({
265
+ organizationOptions: ctx.context.orgOptions,
266
+ advancedGenerateId: getAdvancedGenerateId(ctx.context.options.advanced),
267
+ databaseGenerateId: ctx.context.options.advanced?.database?.generateId
268
+ }) && !session.user.emailVerified) throw APIError.from("FORBIDDEN", ORGANIZATION_ERROR_CODES.EMAIL_VERIFICATION_REQUIRED_BEFORE_ACCEPTING_OR_REJECTING_INVITATION);
251
269
  const membershipLimit = ctx.context.orgOptions?.membershipLimit || 100;
252
270
  const membersCount = await adapter.countMembers({ organizationId: invitation.organizationId });
253
271
  const organization = await adapter.findOrganizationById(invitation.organizationId);
@@ -336,7 +354,11 @@ const rejectInvitation = (options) => createAuthEndpoint("/organization/reject-i
336
354
  code: "INVITATION_NOT_FOUND"
337
355
  });
338
356
  if (invitation.email.toLowerCase() !== session.user.email.toLowerCase()) throw APIError.from("FORBIDDEN", ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION);
339
- if ((ctx.context.orgOptions.requireEmailVerificationOnInvitation ?? true) && !session.user.emailVerified) throw APIError.from("FORBIDDEN", ORGANIZATION_ERROR_CODES.EMAIL_VERIFICATION_REQUIRED_BEFORE_ACCEPTING_OR_REJECTING_INVITATION);
357
+ if (shouldRequireVerifiedEmailForInvitationIdAction({
358
+ organizationOptions: ctx.context.orgOptions,
359
+ advancedGenerateId: getAdvancedGenerateId(ctx.context.options.advanced),
360
+ databaseGenerateId: ctx.context.options.advanced?.database?.generateId
361
+ }) && !session.user.emailVerified) throw APIError.from("FORBIDDEN", ORGANIZATION_ERROR_CODES.EMAIL_VERIFICATION_REQUIRED_BEFORE_ACCEPTING_OR_REJECTING_INVITATION);
340
362
  const organization = await adapter.findOrganizationById(invitation.organizationId);
341
363
  if (!organization) throw APIError.from("BAD_REQUEST", ORGANIZATION_ERROR_CODES.ORGANIZATION_NOT_FOUND);
342
364
  if (options?.organizationHooks?.beforeRejectInvitation) await options?.organizationHooks.beforeRejectInvitation({
@@ -455,7 +477,11 @@ const getInvitation = (options) => createAuthEndpoint("/organization/get-invitat
455
477
  const invitation = await adapter.findInvitationById(ctx.query.id);
456
478
  if (!invitation || invitation.status !== "pending" || invitation.expiresAt < /* @__PURE__ */ new Date()) throw APIError.fromStatus("BAD_REQUEST", { message: "Invitation not found!" });
457
479
  if (invitation.email.toLowerCase() !== session.user.email.toLowerCase()) throw APIError.from("FORBIDDEN", ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION);
458
- if ((ctx.context.orgOptions.requireEmailVerificationOnInvitation ?? true) && !session.user.emailVerified) throw APIError.from("FORBIDDEN", ORGANIZATION_ERROR_CODES.EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION);
480
+ if (shouldRequireVerifiedEmailForInvitationIdAction({
481
+ organizationOptions: ctx.context.orgOptions,
482
+ advancedGenerateId: getAdvancedGenerateId(ctx.context.options.advanced),
483
+ databaseGenerateId: ctx.context.options.advanced?.database?.generateId
484
+ }) && !session.user.emailVerified) throw APIError.from("FORBIDDEN", ORGANIZATION_ERROR_CODES.EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION);
459
485
  const organization = await adapter.findOrganizationById(invitation.organizationId);
460
486
  if (!organization) throw APIError.from("BAD_REQUEST", ORGANIZATION_ERROR_CODES.ORGANIZATION_NOT_FOUND);
461
487
  const member = await adapter.findMemberByOrgId({
@@ -541,7 +567,7 @@ const listUserInvitations = (options) => createAuthEndpoint("/organization/list-
541
567
  }, async (ctx) => {
542
568
  const session = await getSessionFromCtx(ctx);
543
569
  if (ctx.request && ctx.query?.email) throw APIError.fromStatus("BAD_REQUEST", { message: "User email cannot be passed for client side API calls." });
544
- if (session && (ctx.context.orgOptions.requireEmailVerificationOnInvitation ?? true) && !session.user.emailVerified) throw APIError.from("FORBIDDEN", ORGANIZATION_ERROR_CODES.EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION);
570
+ if (session && !session.user.emailVerified) throw APIError.from("FORBIDDEN", ORGANIZATION_ERROR_CODES.EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION);
545
571
  const userEmail = session?.user.email || ctx.query?.email;
546
572
  if (!userEmail) throw APIError.fromStatus("BAD_REQUEST", { message: "Missing session headers, or email query parameter." });
547
573
  const pendingInvitations = (await getOrgAdapter(ctx.context, options).listUserInvitations(userEmail)).filter((inv) => inv.status === "pending");
@@ -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) => {
@@ -165,19 +165,26 @@ interface OrganizationOptions {
165
165
  */
166
166
  cancelPendingInvitationsOnReInvite?: boolean | undefined;
167
167
  /**
168
- * Require email verification on session-authenticated recipient invitation
169
- * calls (accept, reject, get, list). Defaults to `true` so unverified
170
- * accounts registered against a victim's email cannot accept, read, or
171
- * enumerate invitations targeted at that email. Server-side
172
- * `listUserInvitations` calls without a session (caller passes
173
- * `ctx.query.email`) continue to bypass the gate because the caller is
174
- * trusted. Set to `false` for backward compatibility on apps that do not
175
- * require email verification; understand the takeover risk before doing so.
168
+ * Require email verification before session-authenticated recipient
169
+ * invitation calls that carry an invitation ID (accept, reject, get).
176
170
  *
177
- * @default true
171
+ * When unset, Better Auth preserves the normal emailed-invitation flow for
172
+ * built-in opaque invitation IDs, including the default generator and
173
+ * `advanced.database.generateId: "uuid"`. It requires verification for
174
+ * externally controlled or predictable invitation IDs, such as
175
+ * `advanced.database.generateId: "serial"` / `false` or custom ID
176
+ * generation.
177
+ *
178
+ * Set this option to `true` when invitation IDs may be visible outside the
179
+ * invited user's mailbox, when organization invitation lists are exposed to
180
+ * members, or when verified email should be the ownership proof for by-ID
181
+ * invitation actions. Client-side `listUserInvitations` calls always require
182
+ * a verified session email because they enumerate invitation IDs from
183
+ * `session.user.email`. Server-side `listUserInvitations` calls without a
184
+ * session (caller passes `ctx.query.email`) continue to bypass the gate
185
+ * because the caller is trusted.
178
186
  *
179
- * @deprecated The option will be removed on the next minor; the gate will
180
- * become unconditional. Plan to verify emails before invitation acceptance.
187
+ * @default undefined
181
188
  */
182
189
  requireEmailVerificationOnInvitation?: boolean | undefined;
183
190
  /**
@@ -317,7 +324,7 @@ interface OrganizationOptions {
317
324
  organization: {
318
325
  name?: string;
319
326
  slug?: string;
320
- logo?: string;
327
+ logo?: string | null;
321
328
  metadata?: Record<string, any>;
322
329
  [key: string]: any;
323
330
  };
@@ -348,7 +355,7 @@ interface OrganizationOptions {
348
355
  organization: {
349
356
  name?: string;
350
357
  slug?: string;
351
- logo?: string;
358
+ logo?: string | null;
352
359
  metadata?: Record<string, any>;
353
360
  [key: string]: any;
354
361
  };
@@ -358,7 +365,7 @@ interface OrganizationOptions {
358
365
  data: {
359
366
  name?: string;
360
367
  slug?: string;
361
- logo?: string;
368
+ logo?: string | null;
362
369
  metadata?: Record<string, any>;
363
370
  [key: string]: any;
364
371
  };
@@ -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
  }],