better-auth 1.7.15 → 1.7.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/api/index.d.mts +2 -1
  2. package/dist/api/index.mjs +3 -2
  3. package/dist/api/routes/account.mjs +14 -6
  4. package/dist/api/routes/email-verification.mjs +29 -37
  5. package/dist/api/routes/sign-in.mjs +2 -2
  6. package/dist/api/routes/sign-up.mjs +1 -1
  7. package/dist/api/routes/update-user.mjs +10 -4
  8. package/dist/api/state/session-source.d.mts +10 -0
  9. package/dist/api/state/session-source.mjs +28 -0
  10. package/dist/auth/trusted-origins.mjs +33 -8
  11. package/dist/client/index.d.mts +2 -1
  12. package/dist/cookies/index.mjs +1 -0
  13. package/dist/db/internal-adapter.mjs +28 -10
  14. package/dist/index.d.mts +2 -2
  15. package/dist/index.mjs +2 -2
  16. package/dist/oauth2/link-account.d.mts +3 -5
  17. package/dist/oauth2/link-account.mjs +33 -3
  18. package/dist/oauth2/state.d.mts +6 -5
  19. package/dist/package.mjs +1 -1
  20. package/dist/plugins/admin/access/statement.d.mts +10 -10
  21. package/dist/plugins/admin/access/statement.mjs +2 -0
  22. package/dist/plugins/admin/admin.d.mts +4 -3
  23. package/dist/plugins/admin/client.d.mts +5 -4
  24. package/dist/plugins/admin/error-codes.d.mts +1 -0
  25. package/dist/plugins/admin/error-codes.mjs +1 -0
  26. package/dist/plugins/admin/routes.mjs +8 -0
  27. package/dist/plugins/anonymous/index.mjs +2 -1
  28. package/dist/plugins/device-authorization/routes.mjs +4 -4
  29. package/dist/plugins/email-otp/routes.mjs +33 -50
  30. package/dist/plugins/generic-oauth/index.mjs +54 -30
  31. package/dist/plugins/index.d.mts +2 -1
  32. package/dist/plugins/index.mjs +2 -1
  33. package/dist/plugins/oauth-proxy/index.mjs +1 -1
  34. package/dist/plugins/one-tap/index.mjs +1 -1
  35. package/dist/plugins/organization/adapter.mjs +1 -0
  36. package/dist/plugins/organization/client.d.mts +1 -0
  37. package/dist/plugins/organization/error-codes.d.mts +1 -0
  38. package/dist/plugins/organization/error-codes.mjs +1 -0
  39. package/dist/plugins/organization/has-permission.d.mts +2 -3
  40. package/dist/plugins/organization/has-permission.mjs +62 -23
  41. package/dist/plugins/organization/index.d.mts +2 -1
  42. package/dist/plugins/organization/index.mjs +2 -1
  43. package/dist/plugins/organization/organization.d.mts +2 -2
  44. package/dist/plugins/organization/organization.mjs +1 -3
  45. package/dist/plugins/organization/permission.mjs +3 -3
  46. package/dist/plugins/organization/roles.d.mts +8 -0
  47. package/dist/plugins/organization/roles.mjs +26 -0
  48. package/dist/plugins/organization/routes/crud-access-control.mjs +2 -1
  49. package/dist/plugins/organization/routes/crud-invites.mjs +22 -11
  50. package/dist/plugins/organization/routes/crud-members.mjs +10 -14
  51. package/dist/plugins/organization/routes/crud-org.mjs +12 -3
  52. package/dist/plugins/phone-number/client.d.mts +2 -0
  53. package/dist/plugins/phone-number/error-codes.d.mts +2 -0
  54. package/dist/plugins/phone-number/error-codes.mjs +2 -0
  55. package/dist/plugins/phone-number/index.d.mts +25 -1
  56. package/dist/plugins/phone-number/index.mjs +22 -6
  57. package/dist/plugins/phone-number/routes.mjs +34 -35
  58. package/dist/plugins/two-factor/backup-codes/index.d.mts +2 -2
  59. package/dist/plugins/two-factor/index.d.mts +15 -8
  60. package/dist/plugins/two-factor/index.mjs +74 -26
  61. package/dist/plugins/two-factor/otp/index.d.mts +2 -2
  62. package/dist/plugins/two-factor/totp/index.d.mts +2 -2
  63. package/dist/plugins/two-factor/verify-two-factor.mjs +4 -2
  64. package/dist/plugins/username/index.mjs +1 -1
  65. package/dist/state.d.mts +6 -0
  66. package/dist/state.mjs +5 -2
  67. package/dist/utils/index.d.mts +2 -2
  68. package/package.json +11 -11
@@ -1,5 +1,6 @@
1
1
  import { Account, User } from "../types/models.mjs";
2
2
  import "../types/index.mjs";
3
+ import { StateData } from "../state.mjs";
3
4
  import { GenericEndpointContext, UserProvisioningSource } from "@better-auth/core";
4
5
  //#region src/oauth2/link-account.d.ts
5
6
  type OAuthAccountData = Omit<Account, "id" | "userId" | "createdAt" | "updatedAt">;
@@ -16,10 +17,7 @@ type LinkedProviderProfile = {
16
17
  image?: string | null | undefined;
17
18
  };
18
19
  interface LinkOAuthAccountOptions {
19
- link: {
20
- userId: string;
21
- email: string;
22
- };
20
+ link: NonNullable<StateData["link"]>;
23
21
  userInfo: LinkedProviderProfile;
24
22
  account: OAuthAccountData;
25
23
  profile: Record<string, unknown>;
@@ -35,7 +33,7 @@ interface LinkOAuthAccountFailure {
35
33
  type LinkOAuthAccountResult = {
36
34
  linked: true;
37
35
  } | LinkOAuthAccountFailure;
38
- export declare function linkOAuthAccount(c: GenericEndpointContext, { link, userInfo, account, profile, scopes }: LinkOAuthAccountOptions): Promise<LinkOAuthAccountResult>;
36
+ export declare function linkOAuthAccount(c: GenericEndpointContext, opts: LinkOAuthAccountOptions): Promise<LinkOAuthAccountResult>;
39
37
  export declare function handleOAuthUserInfo(c: GenericEndpointContext, opts: {
40
38
  userInfo: Omit<User, "createdAt" | "updatedAt">;
41
39
  account: OAuthAccountData;
@@ -1,8 +1,12 @@
1
1
  import { isAPIError } from "../utils/is-api-error.mjs";
2
2
  import { parseAdditionalUserInputFromProviderProfile } from "../db/schema.mjs";
3
+ import { symmetricDecrypt } from "../crypto/index.mjs";
3
4
  import { setAccountCookie } from "../cookies/session-store.mjs";
4
5
  import { assertValidUserInfo } from "../utils/validate-user-info.mjs";
6
+ import { derivePurposeKey } from "../crypto/purpose.mjs";
5
7
  import { OAUTH_CALLBACK_ERROR_CODES, redirectOnError } from "./errors.mjs";
8
+ import { isStateful } from "../api/routes/session.mjs";
9
+ import { toBoolean } from "../utils/boolean.mjs";
6
10
  import { setTokenUtil } from "./utils.mjs";
7
11
  import { createEmailVerificationToken } from "../api/routes/email-verification.mjs";
8
12
  import { queueAfterTransactionHook, runWithTransaction } from "@better-auth/core/context";
@@ -19,7 +23,29 @@ function linkOAuthAccountFailure(code, message) {
19
23
  }
20
24
  };
21
25
  }
22
- async function linkOAuthAccount(c, { link, userInfo, account, profile, scopes }) {
26
+ /**
27
+ * Whether the session that started a link is still live when the provider
28
+ * returns. Links whose state carries no session token (started before the
29
+ * token was recorded, or built by a direct caller) are not checked.
30
+ */
31
+ async function isLinkSessionLive(c, link) {
32
+ if (!link.encryptedSessionToken || !isStateful(c)) return true;
33
+ const sessionToken = await symmetricDecrypt({
34
+ key: derivePurposeKey(c.context.secretConfig, "oauth-link-session-token"),
35
+ data: link.encryptedSessionToken
36
+ }).catch(() => null);
37
+ if (!sessionToken) return false;
38
+ const source = await c.context.internalAdapter.findSession(sessionToken);
39
+ if (!source) return false;
40
+ return source.session.userId === link.userId && source.session.expiresAt > /* @__PURE__ */ new Date();
41
+ }
42
+ async function linkOAuthAccount(c, opts) {
43
+ const { link, account, profile, scopes } = opts;
44
+ const userInfo = {
45
+ ...opts.userInfo,
46
+ emailVerified: toBoolean(opts.userInfo.emailVerified)
47
+ };
48
+ if (!await isLinkSessionLive(c, link)) return linkOAuthAccountFailure(OAUTH_CALLBACK_ERROR_CODES.UNABLE_TO_LINK_ACCOUNT);
23
49
  try {
24
50
  await assertValidUserInfo(c, {
25
51
  user: {
@@ -70,7 +96,11 @@ async function linkOAuthAccount(c, { link, userInfo, account, profile, scopes })
70
96
  return { linked: true };
71
97
  }
72
98
  async function handleOAuthUserInfo(c, opts) {
73
- const { userInfo, account, callbackURL, disableSignUp, overrideUserInfo } = opts;
99
+ const { account, callbackURL, disableSignUp, overrideUserInfo } = opts;
100
+ const userInfo = {
101
+ ...opts.userInfo,
102
+ emailVerified: toBoolean(opts.userInfo.emailVerified)
103
+ };
74
104
  const source = opts.source ?? {
75
105
  method: "oauth",
76
106
  oauth: { providerId: account.providerId }
@@ -350,7 +380,7 @@ async function dispatchVerificationEmail(c, user, callbackURL, deferUntilAfterTr
350
380
  if (!sendVerificationEmail) return;
351
381
  const send = async () => {
352
382
  try {
353
- const token = await createEmailVerificationToken(c.context.secret, user.email, void 0, c.context.options.emailVerification?.expiresIn);
383
+ const token = await createEmailVerificationToken(c.context.secret, user.email, void 0, c.context.options.emailVerification?.expiresIn, { userId: user.id });
354
384
  const url = `${c.context.baseURL}/verify-email?token=${token}&callbackURL=${encodeURIComponent(callbackURL || "/")}`;
355
385
  await c.context.runInBackgroundOrAwait(sendVerificationEmail({
356
386
  user,
@@ -1,3 +1,4 @@
1
+ import { StateData } from "../state.mjs";
1
2
  import { GenericEndpointContext } from "@better-auth/core";
2
3
  //#region src/oauth2/state.d.ts
3
4
  /**
@@ -15,11 +16,10 @@ export declare function generateIdTokenNonce(provider: {
15
16
  * by name instead of by position.
16
17
  */
17
18
  export interface GenerateStateOptions {
18
- /** Link target when this flow links a provider identity to an existing user. */
19
- link?: {
20
- email: string;
21
- userId: string;
22
- } | undefined;
19
+ /**
20
+ * Link target when this flow links a provider identity to an existing user.
21
+ */
22
+ link?: StateData["link"];
23
23
  /** Extra data to round-trip through state; `false` writes none. */
24
24
  additionalData?: Record<string, any> | false | undefined;
25
25
  /** The `state` nonce already used to build the authorization URL. Minted when omitted. */
@@ -44,6 +44,7 @@ export declare function parseState(c: GenericEndpointContext): Promise<{
44
44
  link?: {
45
45
  email: string;
46
46
  userId: string;
47
+ encryptedSessionToken?: string | undefined;
47
48
  } | undefined;
48
49
  requestSignUp?: boolean | undefined;
49
50
  idTokenNonce?: string | undefined;
package/dist/package.mjs CHANGED
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "1.7.15";
2
+ var version = "1.7.17";
3
3
  //#endregion
4
4
  export { version };
@@ -2,49 +2,49 @@ import { ExactRoleStatements, Role, RoleInput, Statements } from "../../access/t
2
2
  import "../../index.mjs";
3
3
  //#region src/plugins/admin/access/statement.d.ts
4
4
  export declare const defaultStatements: {
5
- readonly user: readonly ["create", "list", "set-role", "ban", "impersonate", "impersonate-admins", "delete", "set-password", "set-email", "get", "update"];
5
+ readonly user: readonly ["create", "list", "set-role", "ban", "impersonate", "impersonate-admins", "delete", "set-password", "set-email", "set-phone", "get", "update"];
6
6
  readonly session: readonly ["list", "revoke", "delete"];
7
7
  };
8
8
  export declare const defaultAc: {
9
9
  newRole<const TRoleStatements extends Statements>(statements: RoleInput<{
10
- readonly user: readonly ["create", "list", "set-role", "ban", "impersonate", "impersonate-admins", "delete", "set-password", "set-email", "get", "update"];
10
+ readonly user: readonly ["create", "list", "set-role", "ban", "impersonate", "impersonate-admins", "delete", "set-password", "set-email", "set-phone", "get", "update"];
11
11
  readonly session: readonly ["list", "revoke", "delete"];
12
12
  }, TRoleStatements>): Role<ExactRoleStatements<TRoleStatements>, {
13
- readonly user: readonly ["create", "list", "set-role", "ban", "impersonate", "impersonate-admins", "delete", "set-password", "set-email", "get", "update"];
13
+ readonly user: readonly ["create", "list", "set-role", "ban", "impersonate", "impersonate-admins", "delete", "set-password", "set-email", "set-phone", "get", "update"];
14
14
  readonly session: readonly ["list", "revoke", "delete"];
15
15
  }>;
16
16
  statements: {
17
- readonly user: readonly ["create", "list", "set-role", "ban", "impersonate", "impersonate-admins", "delete", "set-password", "set-email", "get", "update"];
17
+ readonly user: readonly ["create", "list", "set-role", "ban", "impersonate", "impersonate-admins", "delete", "set-password", "set-email", "set-phone", "get", "update"];
18
18
  readonly session: readonly ["list", "revoke", "delete"];
19
19
  };
20
20
  };
21
21
  export declare const adminAc: Role<ExactRoleStatements<{
22
- readonly user: ["create", "list", "set-role", "ban", "impersonate", "delete", "set-password", "set-email", "get", "update"];
22
+ readonly user: ["create", "list", "set-role", "ban", "impersonate", "delete", "set-password", "set-email", "set-phone", "get", "update"];
23
23
  readonly session: ["list", "revoke", "delete"];
24
24
  }>, {
25
- readonly user: readonly ["create", "list", "set-role", "ban", "impersonate", "impersonate-admins", "delete", "set-password", "set-email", "get", "update"];
25
+ readonly user: readonly ["create", "list", "set-role", "ban", "impersonate", "impersonate-admins", "delete", "set-password", "set-email", "set-phone", "get", "update"];
26
26
  readonly session: readonly ["list", "revoke", "delete"];
27
27
  }>;
28
28
  export declare const userAc: Role<ExactRoleStatements<{
29
29
  readonly user: [];
30
30
  readonly session: [];
31
31
  }>, {
32
- readonly user: readonly ["create", "list", "set-role", "ban", "impersonate", "impersonate-admins", "delete", "set-password", "set-email", "get", "update"];
32
+ readonly user: readonly ["create", "list", "set-role", "ban", "impersonate", "impersonate-admins", "delete", "set-password", "set-email", "set-phone", "get", "update"];
33
33
  readonly session: readonly ["list", "revoke", "delete"];
34
34
  }>;
35
35
  export declare const defaultRoles: {
36
36
  admin: Role<ExactRoleStatements<{
37
- readonly user: ["create", "list", "set-role", "ban", "impersonate", "delete", "set-password", "set-email", "get", "update"];
37
+ readonly user: ["create", "list", "set-role", "ban", "impersonate", "delete", "set-password", "set-email", "set-phone", "get", "update"];
38
38
  readonly session: ["list", "revoke", "delete"];
39
39
  }>, {
40
- readonly user: readonly ["create", "list", "set-role", "ban", "impersonate", "impersonate-admins", "delete", "set-password", "set-email", "get", "update"];
40
+ readonly user: readonly ["create", "list", "set-role", "ban", "impersonate", "impersonate-admins", "delete", "set-password", "set-email", "set-phone", "get", "update"];
41
41
  readonly session: readonly ["list", "revoke", "delete"];
42
42
  }>;
43
43
  user: Role<ExactRoleStatements<{
44
44
  readonly user: [];
45
45
  readonly session: [];
46
46
  }>, {
47
- readonly user: readonly ["create", "list", "set-role", "ban", "impersonate", "impersonate-admins", "delete", "set-password", "set-email", "get", "update"];
47
+ readonly user: readonly ["create", "list", "set-role", "ban", "impersonate", "impersonate-admins", "delete", "set-password", "set-email", "set-phone", "get", "update"];
48
48
  readonly session: readonly ["list", "revoke", "delete"];
49
49
  }>;
50
50
  };
@@ -11,6 +11,7 @@ const defaultStatements = {
11
11
  "delete",
12
12
  "set-password",
13
13
  "set-email",
14
+ "set-phone",
14
15
  "get",
15
16
  "update"
16
17
  ],
@@ -31,6 +32,7 @@ const adminAc = defaultAc.newRole({
31
32
  "delete",
32
33
  "set-password",
33
34
  "set-email",
35
+ "set-phone",
34
36
  "get",
35
37
  "update"
36
38
  ],
@@ -819,13 +819,13 @@ export declare const admin: <O extends AdminOptions>(options?: O | undefined) =>
819
819
  $Infer: {
820
820
  body: {
821
821
  permissions: { [key in keyof (O["ac"] extends AccessControl<infer S extends Statements> ? S : {
822
- readonly user: readonly ["create", "list", "set-role", "ban", "impersonate", "impersonate-admins", "delete", "set-password", "set-email", "get", "update"];
822
+ readonly user: readonly ["create", "list", "set-role", "ban", "impersonate", "impersonate-admins", "delete", "set-password", "set-email", "set-phone", "get", "update"];
823
823
  readonly session: readonly ["list", "revoke", "delete"];
824
824
  })]?: ((O["ac"] extends AccessControl<infer S extends Statements> ? S : {
825
- readonly user: readonly ["create", "list", "set-role", "ban", "impersonate", "impersonate-admins", "delete", "set-password", "set-email", "get", "update"];
825
+ readonly user: readonly ["create", "list", "set-role", "ban", "impersonate", "impersonate-admins", "delete", "set-password", "set-email", "set-phone", "get", "update"];
826
826
  readonly session: readonly ["list", "revoke", "delete"];
827
827
  })[key] extends readonly unknown[] ? ArrayElement<(O["ac"] extends AccessControl<infer S extends Statements> ? S : {
828
- readonly user: readonly ["create", "list", "set-role", "ban", "impersonate", "impersonate-admins", "delete", "set-password", "set-email", "get", "update"];
828
+ readonly user: readonly ["create", "list", "set-role", "ban", "impersonate", "impersonate-admins", "delete", "set-password", "set-email", "set-phone", "get", "update"];
829
829
  readonly session: readonly ["list", "revoke", "delete"];
830
830
  })[key]> : never)[] | undefined; };
831
831
  } & {
@@ -862,6 +862,7 @@ export declare const admin: <O extends AdminOptions>(options?: O | undefined) =>
862
862
  YOU_CANNOT_IMPERSONATE_ADMINS: import("@better-auth/core/utils/error-codes").RawError<"YOU_CANNOT_IMPERSONATE_ADMINS">;
863
863
  INVALID_ROLE_TYPE: import("@better-auth/core/utils/error-codes").RawError<"INVALID_ROLE_TYPE">;
864
864
  YOU_ARE_NOT_ALLOWED_TO_SET_USERS_EMAIL: import("@better-auth/core/utils/error-codes").RawError<"YOU_ARE_NOT_ALLOWED_TO_SET_USERS_EMAIL">;
865
+ YOU_ARE_NOT_ALLOWED_TO_SET_USERS_PHONE_NUMBER: import("@better-auth/core/utils/error-codes").RawError<"YOU_ARE_NOT_ALLOWED_TO_SET_USERS_PHONE_NUMBER">;
865
866
  PASSWORD_CANNOT_BE_UPDATED_VIA_UPDATE_USER: import("@better-auth/core/utils/error-codes").RawError<"PASSWORD_CANNOT_BE_UPDATED_VIA_UPDATE_USER">;
866
867
  USER_ID_CANNOT_BE_UPDATED: import("@better-auth/core/utils/error-codes").RawError<"USER_ID_CANNOT_BE_UPDATED">;
867
868
  };
@@ -14,7 +14,7 @@ export declare const adminClient: <O extends AdminClientOptions>(options?: O | u
14
14
  version: string;
15
15
  $InferServerPlugin: ReturnType<typeof admin<{
16
16
  ac: O["ac"] extends AccessControl ? O["ac"] : AccessControl<{
17
- readonly user: readonly ["create", "list", "set-role", "ban", "impersonate", "impersonate-admins", "delete", "set-password", "set-email", "get", "update"];
17
+ readonly user: readonly ["create", "list", "set-role", "ban", "impersonate", "impersonate-admins", "delete", "set-password", "set-email", "set-phone", "get", "update"];
18
18
  readonly session: readonly ["list", "revoke", "delete"];
19
19
  }>;
20
20
  roles: O["roles"] extends Record<string, Role> ? O["roles"] : {
@@ -28,13 +28,13 @@ export declare const adminClient: <O extends AdminClientOptions>(options?: O | u
28
28
  roles: any;
29
29
  } ? keyof O["roles"] : "admin" | "user">(data: {
30
30
  permissions: { [key in keyof (O["ac"] extends AccessControl<infer S extends Statements> ? S : {
31
- readonly user: readonly ["create", "list", "set-role", "ban", "impersonate", "impersonate-admins", "delete", "set-password", "set-email", "get", "update"];
31
+ readonly user: readonly ["create", "list", "set-role", "ban", "impersonate", "impersonate-admins", "delete", "set-password", "set-email", "set-phone", "get", "update"];
32
32
  readonly session: readonly ["list", "revoke", "delete"];
33
33
  })]?: ((O["ac"] extends AccessControl<infer S extends Statements> ? S : {
34
- readonly user: readonly ["create", "list", "set-role", "ban", "impersonate", "impersonate-admins", "delete", "set-password", "set-email", "get", "update"];
34
+ readonly user: readonly ["create", "list", "set-role", "ban", "impersonate", "impersonate-admins", "delete", "set-password", "set-email", "set-phone", "get", "update"];
35
35
  readonly session: readonly ["list", "revoke", "delete"];
36
36
  })[key] extends readonly unknown[] ? ArrayElement<(O["ac"] extends AccessControl<infer S extends Statements> ? S : {
37
- readonly user: readonly ["create", "list", "set-role", "ban", "impersonate", "impersonate-admins", "delete", "set-password", "set-email", "get", "update"];
37
+ readonly user: readonly ["create", "list", "set-role", "ban", "impersonate", "impersonate-admins", "delete", "set-password", "set-email", "set-phone", "get", "update"];
38
38
  readonly session: readonly ["list", "revoke", "delete"];
39
39
  })[key]> : never)[] | undefined; };
40
40
  } & {
@@ -74,6 +74,7 @@ export declare const adminClient: <O extends AdminClientOptions>(options?: O | u
74
74
  YOU_CANNOT_IMPERSONATE_ADMINS: import("@better-auth/core/utils/error-codes").RawError<"YOU_CANNOT_IMPERSONATE_ADMINS">;
75
75
  INVALID_ROLE_TYPE: import("@better-auth/core/utils/error-codes").RawError<"INVALID_ROLE_TYPE">;
76
76
  YOU_ARE_NOT_ALLOWED_TO_SET_USERS_EMAIL: import("@better-auth/core/utils/error-codes").RawError<"YOU_ARE_NOT_ALLOWED_TO_SET_USERS_EMAIL">;
77
+ YOU_ARE_NOT_ALLOWED_TO_SET_USERS_PHONE_NUMBER: import("@better-auth/core/utils/error-codes").RawError<"YOU_ARE_NOT_ALLOWED_TO_SET_USERS_PHONE_NUMBER">;
77
78
  PASSWORD_CANNOT_BE_UPDATED_VIA_UPDATE_USER: import("@better-auth/core/utils/error-codes").RawError<"PASSWORD_CANNOT_BE_UPDATED_VIA_UPDATE_USER">;
78
79
  USER_ID_CANNOT_BE_UPDATED: import("@better-auth/core/utils/error-codes").RawError<"USER_ID_CANNOT_BE_UPDATED">;
79
80
  };
@@ -22,6 +22,7 @@ export declare const ADMIN_ERROR_CODES: {
22
22
  YOU_CANNOT_IMPERSONATE_ADMINS: import("@better-auth/core/utils/error-codes").RawError<"YOU_CANNOT_IMPERSONATE_ADMINS">;
23
23
  INVALID_ROLE_TYPE: import("@better-auth/core/utils/error-codes").RawError<"INVALID_ROLE_TYPE">;
24
24
  YOU_ARE_NOT_ALLOWED_TO_SET_USERS_EMAIL: import("@better-auth/core/utils/error-codes").RawError<"YOU_ARE_NOT_ALLOWED_TO_SET_USERS_EMAIL">;
25
+ YOU_ARE_NOT_ALLOWED_TO_SET_USERS_PHONE_NUMBER: import("@better-auth/core/utils/error-codes").RawError<"YOU_ARE_NOT_ALLOWED_TO_SET_USERS_PHONE_NUMBER">;
25
26
  PASSWORD_CANNOT_BE_UPDATED_VIA_UPDATE_USER: import("@better-auth/core/utils/error-codes").RawError<"PASSWORD_CANNOT_BE_UPDATED_VIA_UPDATE_USER">;
26
27
  USER_ID_CANNOT_BE_UPDATED: import("@better-auth/core/utils/error-codes").RawError<"USER_ID_CANNOT_BE_UPDATED">;
27
28
  };
@@ -23,6 +23,7 @@ const ADMIN_ERROR_CODES = defineErrorCodes({
23
23
  YOU_CANNOT_IMPERSONATE_ADMINS: "You cannot impersonate admins",
24
24
  INVALID_ROLE_TYPE: "Invalid role type",
25
25
  YOU_ARE_NOT_ALLOWED_TO_SET_USERS_EMAIL: "You are not allowed to update users email",
26
+ YOU_ARE_NOT_ALLOWED_TO_SET_USERS_PHONE_NUMBER: "You are not allowed to update users phone number",
26
27
  PASSWORD_CANNOT_BE_UPDATED_VIA_UPDATE_USER: "Password cannot be updated through update-user. Use the set-user-password endpoint instead",
27
28
  USER_ID_CANNOT_BE_UPDATED: "User id cannot be updated"
28
29
  });
@@ -301,6 +301,14 @@ const adminUpdateUser = (opts) => createAuthEndpoint("/admin/update-user", {
301
301
  updateData.email = email;
302
302
  }
303
303
  }
304
+ if (hasDataKey("phoneNumber") || hasDataKey("phoneNumberVerified")) {
305
+ if (!hasPermission({
306
+ userId: ctx.context.session.user.id,
307
+ role: ctx.context.session.user.role,
308
+ options: opts,
309
+ permissions: { user: ["set-phone"] }
310
+ })) throw APIError.from("FORBIDDEN", ADMIN_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_SET_USERS_PHONE_NUMBER);
311
+ }
304
312
  if (!await ctx.context.internalAdapter.findUserById(ctx.body.userId)) throw APIError.from("NOT_FOUND", BASE_ERROR_CODES.USER_NOT_FOUND);
305
313
  const updatedUser = await ctx.context.internalAdapter.updateUser(ctx.body.userId, ctx.body.data);
306
314
  if (updateData.banned === true) await ctx.context.internalAdapter.deleteUserSessions(ctx.body.userId);
@@ -1,8 +1,8 @@
1
1
  import { mergeSchema, parseUserOutput } from "../../db/schema.mjs";
2
2
  import { parseSetCookieHeader } from "../../cookies/cookie-utils.mjs";
3
3
  import { deleteSessionCookie, setSessionCookie } from "../../cookies/index.mjs";
4
- import { addOAuthServerContext, getOAuthState } from "../../api/state/oauth.mjs";
5
4
  import { getSessionFromCtx, sensitiveSessionMiddleware } from "../../api/routes/session.mjs";
5
+ import { addOAuthServerContext, getOAuthState } from "../../api/state/oauth.mjs";
6
6
  import { APIError } from "../../api/index.mjs";
7
7
  import { PACKAGE_VERSION } from "../../version.mjs";
8
8
  import { ANONYMOUS_ERROR_CODES } from "./error-codes.mjs";
@@ -187,6 +187,7 @@ const anonymous = (options) => {
187
187
  if (ctx.path === "/sign-in/anonymous" && !ctx.context.newSession) throw APIError.from("BAD_REQUEST", ANONYMOUS_ERROR_CODES.ANONYMOUS_USERS_CANNOT_SIGN_IN_AGAIN_ANONYMOUSLY);
188
188
  const newSession = ctx.context.newSession;
189
189
  if (!newSession) return;
190
+ if (await ctx.context.getPlugin("two-factor")?.shouldRequireTwoFactor(ctx)) return;
190
191
  if (options?.onLinkAccount) await options.onLinkAccount({
191
192
  anonymousUser: {
192
193
  session: session.session,
@@ -1,6 +1,6 @@
1
1
  import { ms } from "../../utils/time.mjs";
2
2
  import { generateRandomString } from "../../crypto/random.mjs";
3
- import { getSessionFromCtx } from "../../api/routes/session.mjs";
3
+ import { getAuthoritativeSessionFromCtx } from "../../api/routes/session.mjs";
4
4
  import { DEVICE_AUTHORIZATION_ERROR_CODES } from "./error-codes.mjs";
5
5
  import { APIError } from "@better-auth/core/error";
6
6
  import { createAuthEndpoint } from "@better-auth/core/api";
@@ -526,7 +526,7 @@ const deviceVerify = (grant) => createAuthEndpoint("/device", {
526
526
  error: "expired_token",
527
527
  error_description: DEVICE_AUTHORIZATION_ERROR_CODES.EXPIRED_USER_CODE.message
528
528
  });
529
- const session = await getSessionFromCtx(ctx);
529
+ const session = await getAuthoritativeSessionFromCtx(ctx);
530
530
  if (session?.user?.id && !deviceCodeRecord.userId && deviceCodeRecord.status === "pending") {
531
531
  if (await ctx.context.adapter.incrementOne({
532
532
  model: "deviceCode",
@@ -587,7 +587,7 @@ const deviceApprove = createAuthEndpoint("/device/approve", {
587
587
  } }
588
588
  } }
589
589
  }, async (ctx) => {
590
- const session = await getSessionFromCtx(ctx);
590
+ const session = await getAuthoritativeSessionFromCtx(ctx);
591
591
  if (!session) throw new APIError("UNAUTHORIZED", {
592
592
  error: "unauthorized",
593
593
  error_description: DEVICE_AUTHORIZATION_ERROR_CODES.AUTHENTICATION_REQUIRED.message
@@ -651,7 +651,7 @@ const deviceDeny = createAuthEndpoint("/device/deny", {
651
651
  } }
652
652
  } }
653
653
  }, async (ctx) => {
654
- const session = await getSessionFromCtx(ctx);
654
+ const session = await getAuthoritativeSessionFromCtx(ctx);
655
655
  if (!session) throw new APIError("UNAUTHORIZED", {
656
656
  error: "unauthorized",
657
657
  error_description: DEVICE_AUTHORIZATION_ERROR_CODES.AUTHENTICATION_REQUIRED.message
@@ -101,12 +101,13 @@ const sendVerificationOTP = (opts) => createAuthEndpoint("/email-otp/send-verifi
101
101
  const identifier = toOTPIdentifier(ctx.body.type, email);
102
102
  const otp = await resolveOTP(ctx, opts, email, ctx.body.type);
103
103
  const shouldSendOTP = ctx.body.type === "sign-in" && !opts.disableSignUp;
104
- if (!await ctx.context.internalAdapter.findUserByEmail(email) && !shouldSendOTP) {
104
+ const user = await ctx.context.internalAdapter.findUserByEmail(email);
105
+ if (!user && !shouldSendOTP) {
105
106
  await ctx.context.internalAdapter.deleteVerificationByIdentifier(identifier);
106
107
  return ctx.json({ success: true });
107
108
  }
108
109
  await ctx.context.runInBackgroundOrAwait(opts.sendVerificationOTP({
109
- email,
110
+ email: user?.user.email ?? email,
110
111
  otp,
111
112
  type: ctx.body.type
112
113
  }, ctx));
@@ -236,23 +237,7 @@ const checkVerificationOTP = (opts) => createAuthEndpoint("/email-otp/check-veri
236
237
  }, async (ctx) => {
237
238
  const email = ctx.body.email.toLowerCase();
238
239
  if (!z.email().safeParse(email).success) throw APIError$1.from("BAD_REQUEST", BASE_ERROR_CODES.INVALID_EMAIL);
239
- const identifier = toOTPIdentifier(ctx.body.type, email);
240
- const verificationValue = await ctx.context.internalAdapter.findVerificationValue(identifier);
241
- if (!verificationValue) throw APIError$1.from("BAD_REQUEST", EMAIL_OTP_ERROR_CODES.INVALID_OTP);
242
- if (verificationValue.expiresAt < /* @__PURE__ */ new Date()) {
243
- await ctx.context.internalAdapter.deleteVerificationByIdentifier(identifier);
244
- throw APIError$1.from("BAD_REQUEST", EMAIL_OTP_ERROR_CODES.OTP_EXPIRED);
245
- }
246
- const [otpValue, attempts] = splitAtLastColon(verificationValue.value);
247
- const allowedAttempts = opts?.allowedAttempts || 3;
248
- if (attempts && parseInt(attempts) >= allowedAttempts) {
249
- await ctx.context.internalAdapter.deleteVerificationByIdentifier(identifier);
250
- throw APIError$1.from("FORBIDDEN", EMAIL_OTP_ERROR_CODES.TOO_MANY_ATTEMPTS);
251
- }
252
- if (!await verifyStoredOTP(ctx, opts, otpValue, ctx.body.otp)) {
253
- await ctx.context.internalAdapter.updateVerificationByIdentifier(identifier, { value: `${otpValue}:${parseInt(attempts || "0") + 1}` });
254
- throw APIError$1.from("BAD_REQUEST", EMAIL_OTP_ERROR_CODES.INVALID_OTP);
255
- }
240
+ await atomicVerifyOTP(ctx, opts, toOTPIdentifier(ctx.body.type, email), ctx.body.otp, "restore");
256
241
  if (!await ctx.context.internalAdapter.findUserByEmail(email))
257
242
  /**
258
243
  * safe to leak the existence of a user, given the user has already the OTP from the
@@ -480,14 +465,16 @@ const requestPasswordResetEmailOTP = (opts) => createAuthEndpoint("/email-otp/re
480
465
  } }
481
466
  }, async (ctx) => {
482
467
  const email = ctx.body.email.toLowerCase();
468
+ if (!z.email().safeParse(email).success) throw APIError$1.from("BAD_REQUEST", BASE_ERROR_CODES.INVALID_EMAIL);
483
469
  const identifier = toOTPIdentifier("forget-password", email);
484
470
  const otp = await resolveOTP(ctx, opts, email, "forget-password");
485
- if (!await ctx.context.internalAdapter.findUserByEmail(email)) {
471
+ const user = await ctx.context.internalAdapter.findUserByEmail(email);
472
+ if (!user) {
486
473
  await ctx.context.internalAdapter.deleteVerificationByIdentifier(identifier);
487
474
  return ctx.json({ success: true });
488
475
  }
489
476
  await ctx.context.runInBackgroundOrAwait(opts.sendVerificationOTP({
490
- email,
477
+ email: user.user.email,
491
478
  otp,
492
479
  type: "forget-password"
493
480
  }, ctx));
@@ -532,14 +519,16 @@ const forgetPasswordEmailOTP = (opts) => {
532
519
  }, async (ctx) => {
533
520
  warnDeprecation();
534
521
  const email = ctx.body.email.toLowerCase();
522
+ if (!z.email().safeParse(email).success) throw APIError$1.from("BAD_REQUEST", BASE_ERROR_CODES.INVALID_EMAIL);
535
523
  const identifier = toOTPIdentifier("forget-password", email);
536
524
  const otp = await resolveOTP(ctx, opts, email, "forget-password");
537
- if (!await ctx.context.internalAdapter.findUserByEmail(email)) {
525
+ const user = await ctx.context.internalAdapter.findUserByEmail(email);
526
+ if (!user) {
538
527
  await ctx.context.internalAdapter.deleteVerificationByIdentifier(identifier);
539
528
  return ctx.json({ success: true });
540
529
  }
541
530
  await ctx.context.runInBackgroundOrAwait(opts.sendVerificationOTP({
542
- email,
531
+ email: user.user.email,
543
532
  otp,
544
533
  type: "forget-password"
545
534
  }, ctx));
@@ -582,18 +571,15 @@ const resetPasswordEmailOTP = (opts) => createAuthEndpoint("/email-otp/reset-pas
582
571
  } }
583
572
  }, async (ctx) => {
584
573
  const email = ctx.body.email.toLowerCase();
574
+ if (!z.email().safeParse(email).success) throw APIError$1.from("BAD_REQUEST", BASE_ERROR_CODES.INVALID_EMAIL);
585
575
  assertPasswordNotTooShort(ctx, ctx.body.password);
586
576
  assertPasswordNotTooLong(ctx, ctx.body.password);
587
577
  const identifier = toOTPIdentifier("forget-password", email);
588
- let otpConsumed = false;
589
- if (!await matchesUsableStoredOTP(ctx, opts, identifier, ctx.body.otp)) {
590
- await atomicVerifyOTP(ctx, opts, identifier, ctx.body.otp);
591
- otpConsumed = true;
592
- }
578
+ await atomicVerifyOTP(ctx, opts, identifier, ctx.body.otp, "restore");
593
579
  const user = await ctx.context.internalAdapter.findUserByEmail(email);
594
580
  if (!user) throw APIError$1.from("BAD_REQUEST", BASE_ERROR_CODES.USER_NOT_FOUND);
595
581
  const passwordHash = await ctx.context.password.hash(ctx.body.password);
596
- await resetCredentialPassword(ctx, user.user.id, passwordHash, otpConsumed ? void 0 : () => atomicVerifyOTP(ctx, opts, identifier, ctx.body.otp));
582
+ await resetCredentialPassword(ctx, user.user.id, passwordHash, () => atomicVerifyOTP(ctx, opts, identifier, ctx.body.otp));
597
583
  if (ctx.context.options.emailAndPassword?.onPasswordReset) await ctx.context.options.emailAndPassword.onPasswordReset({ user: user.user }, ctx.request);
598
584
  if (!user.user.emailVerified) await ctx.context.internalAdapter.updateUser(user.user.id, { emailVerified: true });
599
585
  return ctx.json({ success: true });
@@ -653,13 +639,14 @@ const requestEmailChangeEmailOTP = (opts) => createAuthEndpoint("/email-otp/requ
653
639
  type: "change-email"
654
640
  }, ctx) || defaultOTPGenerator(opts);
655
641
  const storedOTP = await storeOTP(ctx, opts, otp);
642
+ const identifier = toOTPIdentifier("change-email", `${ctx.context.session.user.id}:${newEmail}`);
656
643
  await ctx.context.internalAdapter.createVerificationValue({
657
644
  value: `${storedOTP}:0`,
658
- identifier: toOTPIdentifier("change-email", `${email}-${newEmail}`),
645
+ identifier,
659
646
  expiresAt: getDate(opts.expiresIn, "sec")
660
647
  });
661
648
  if (await ctx.context.internalAdapter.findUserByEmail(newEmail)) {
662
- await ctx.context.internalAdapter.deleteVerificationByIdentifier(toOTPIdentifier("change-email", `${email}-${newEmail}`));
649
+ await ctx.context.internalAdapter.deleteVerificationByIdentifier(identifier);
663
650
  return ctx.json({ success: true });
664
651
  }
665
652
  await ctx.context.runInBackgroundOrAwait(opts.sendVerificationOTP({
@@ -716,7 +703,7 @@ const changeEmailEmailOTP = (opts) => createAuthEndpoint("/email-otp/change-emai
716
703
  ctx.context.logger.error("Email is the same");
717
704
  throw APIError$1.fromStatus("BAD_REQUEST", { message: "Email is the same" });
718
705
  }
719
- await atomicVerifyOTP(ctx, opts, toOTPIdentifier("change-email", `${email}-${newEmail}`), ctx.body.otp);
706
+ await atomicVerifyOTP(ctx, opts, toOTPIdentifier("change-email", `${session.user.id}:${newEmail}`), ctx.body.otp);
720
707
  const currentUser = await ctx.context.internalAdapter.findUserByEmail(email);
721
708
  if (!currentUser)
722
709
  /**
@@ -746,28 +733,17 @@ const changeEmailEmailOTP = (opts) => createAuthEndpoint("/email-otp/change-emai
746
733
  });
747
734
  const defaultOTPGenerator = (options) => generateRandomString(options.otpLength ?? 6, "0-9");
748
735
  /**
749
- * Reports whether the provided OTP matches a stored, unexpired record with
750
- * attempts left, without consuming it or counting an attempt.
751
- */
752
- async function matchesUsableStoredOTP(ctx, opts, identifier, providedOTP) {
753
- const existing = await ctx.context.internalAdapter.findVerificationValue(identifier);
754
- if (!existing || existing.expiresAt < /* @__PURE__ */ new Date()) return false;
755
- const [otpValue, attempts] = splitAtLastColon(existing.value);
756
- if (parseInt(attempts || "0") >= (opts?.allowedAttempts || 3)) return false;
757
- return verifyStoredOTP(ctx, opts, otpValue, providedOTP);
758
- }
759
- /**
760
- * Verifies a single-use OTP with race-condition protection.
736
+ * Verifies an OTP with race-condition protection.
761
737
  *
762
738
  * The atomic consume is the single gate: only the first concurrent caller
763
739
  * receives the record, every later racer receives `null` and is rejected, so
764
- * a correct OTP can only ever be accepted once. When the submitted code is
765
- * wrong the record is recreated with the same value and expiry and an
766
- * incremented attempt count so the next try can still find it; the budget is
767
- * enforced before verification, and a record whose attempts are exhausted is
768
- * left consumed (no recreate), locking the identifier out.
740
+ * only one caller at a time can compare a guess. Credential routes leave a
741
+ * successful OTP consumed. The optional preflight restores it unchanged so
742
+ * the documented follow-up call can use it. A wrong guess restores it with
743
+ * one additional attempt and the original expiry. An exhausted record stays
744
+ * consumed, locking the identifier out.
769
745
  */
770
- async function atomicVerifyOTP(ctx, opts, identifier, providedOTP) {
746
+ async function atomicVerifyOTP(ctx, opts, identifier, providedOTP, successDisposition = "consume") {
771
747
  const existing = await ctx.context.internalAdapter.findVerificationValue(identifier);
772
748
  if (existing && existing.expiresAt < /* @__PURE__ */ new Date()) {
773
749
  await ctx.context.internalAdapter.deleteVerificationByIdentifier(identifier);
@@ -787,6 +763,13 @@ async function atomicVerifyOTP(ctx, opts, identifier, providedOTP) {
787
763
  });
788
764
  throw APIError$1.from("BAD_REQUEST", EMAIL_OTP_ERROR_CODES.INVALID_OTP);
789
765
  }
766
+ if (successDisposition === "consume") return;
767
+ if (await ctx.context.internalAdapter.findVerificationValue(identifier)) throw APIError$1.from("BAD_REQUEST", EMAIL_OTP_ERROR_CODES.INVALID_OTP);
768
+ if (!await ctx.context.internalAdapter.createVerificationValue({
769
+ value: consumed.value,
770
+ identifier,
771
+ expiresAt: consumed.expiresAt
772
+ })) throw APIError$1.fromStatus("INTERNAL_SERVER_ERROR", { message: "Unable to complete OTP verification" });
790
773
  }
791
774
  //#endregion
792
775
  export { changeEmailEmailOTP, checkVerificationOTP, createVerificationOTP, forgetPasswordEmailOTP, getVerificationOTP, requestEmailChangeEmailOTP, requestPasswordResetEmailOTP, resetPasswordEmailOTP, sendVerificationOTP, signInEmailOTP, verifyEmailOTP };