better-auth 1.6.1 → 1.7.0-beta.0

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 (32) hide show
  1. package/dist/api/index.d.mts +0 -2
  2. package/dist/api/routes/callback.mjs +1 -1
  3. package/dist/api/routes/password.mjs +1 -1
  4. package/dist/api/routes/session.d.mts +0 -1
  5. package/dist/api/routes/session.mjs +1 -2
  6. package/dist/integrations/next-js.mjs +21 -12
  7. package/dist/oauth2/state.d.mts +1 -0
  8. package/dist/package.mjs +1 -1
  9. package/dist/plugins/admin/admin.d.mts +1 -3
  10. package/dist/plugins/admin/routes.mjs +1 -7
  11. package/dist/plugins/generic-oauth/index.mjs +10 -1
  12. package/dist/plugins/generic-oauth/routes.mjs +4 -0
  13. package/dist/plugins/generic-oauth/types.d.mts +7 -2
  14. package/dist/plugins/jwt/utils.d.mts +1 -1
  15. package/dist/plugins/oauth-proxy/index.mjs +4 -0
  16. package/dist/plugins/organization/organization.d.mts +1 -3
  17. package/dist/plugins/organization/organization.mjs +1 -7
  18. package/dist/plugins/two-factor/client.d.mts +13 -1
  19. package/dist/plugins/two-factor/client.mjs +1 -1
  20. package/dist/plugins/two-factor/error-code.d.mts +2 -0
  21. package/dist/plugins/two-factor/error-code.mjs +2 -0
  22. package/dist/plugins/two-factor/index.d.mts +26 -0
  23. package/dist/plugins/two-factor/index.mjs +67 -22
  24. package/dist/plugins/two-factor/otp/index.mjs +1 -1
  25. package/dist/plugins/two-factor/schema.d.mts +6 -0
  26. package/dist/plugins/two-factor/schema.mjs +6 -0
  27. package/dist/plugins/two-factor/totp/index.mjs +19 -10
  28. package/dist/plugins/two-factor/types.d.mts +1 -6
  29. package/dist/state.d.mts +6 -0
  30. package/dist/state.mjs +18 -2
  31. package/dist/test-utils/test-instance.d.mts +0 -6
  32. package/package.json +8 -8
@@ -249,7 +249,6 @@ declare function getEndpoints<Option extends BetterAuthOptions>(ctx: Awaitable<A
249
249
  "application/json": {
250
250
  schema: {
251
251
  type: "object";
252
- nullable: boolean;
253
252
  properties: {
254
253
  session: {
255
254
  $ref: string;
@@ -2239,7 +2238,6 @@ declare const router: <Option extends BetterAuthOptions>(ctx: AuthContext, optio
2239
2238
  "application/json": {
2240
2239
  schema: {
2241
2240
  type: "object";
2242
- nullable: boolean;
2243
2241
  properties: {
2244
2242
  session: {
2245
2243
  $ref: string;
@@ -104,7 +104,7 @@ const callbackOAuth = createAuthEndpoint("/callback/:id", {
104
104
  return redirectOnError("unable_to_link_account");
105
105
  }
106
106
  if (userInfo.email?.toLowerCase() !== link.email.toLowerCase() && c.context.options.account?.accountLinking?.allowDifferentEmails !== true) return redirectOnError("email_doesn't_match");
107
- const existingAccount = await c.context.internalAdapter.findAccount(String(userInfo.id));
107
+ const existingAccount = await c.context.internalAdapter.findAccountByProviderId(String(userInfo.id), provider.id);
108
108
  if (existingAccount) {
109
109
  if (existingAccount.userId.toString() !== link.userId.toString()) return redirectOnError("account_already_linked_to_different_user");
110
110
  const updateData = Object.fromEntries(Object.entries({
@@ -82,7 +82,7 @@ const requestPasswordReset = createAuthEndpoint("/request-password-reset", {
82
82
  });
83
83
  const requestPasswordResetCallback = createAuthEndpoint("/reset-password/:token", {
84
84
  method: "GET",
85
- operationId: "forgetPasswordCallback",
85
+ operationId: "resetPasswordCallback",
86
86
  query: z.object({ callbackURL: z.string().meta({ description: "The URL to redirect the user to reset their password" }) }),
87
87
  use: [originCheck((ctx) => ctx.query.callbackURL)],
88
88
  metadata: { openapi: {
@@ -25,7 +25,6 @@ declare const getSession: <Option extends BetterAuthOptions>() => better_call0.S
25
25
  "application/json": {
26
26
  schema: {
27
27
  type: "object";
28
- nullable: boolean;
29
28
  properties: {
30
29
  session: {
31
30
  $ref: string;
@@ -24,8 +24,7 @@ const getSession = () => createAuthEndpoint("/get-session", {
24
24
  responses: { "200": {
25
25
  description: "Success",
26
26
  content: { "application/json": { schema: {
27
- type: "object",
28
- nullable: true,
27
+ type: ["object", "null"],
29
28
  properties: {
30
29
  session: { $ref: "#/components/schemas/Session" },
31
30
  user: { $ref: "#/components/schemas/User" }
@@ -24,20 +24,29 @@ const nextCookies = () => {
24
24
  matcher(ctx) {
25
25
  return ctx.path === "/get-session";
26
26
  },
27
- handler: createAuthMiddleware(async () => {
28
- let cookieStore;
27
+ handler: createAuthMiddleware(async (ctx) => {
28
+ if ("_flag" in ctx && ctx._flag === "router") return;
29
+ let headersStore;
29
30
  try {
30
- const { cookies } = await import("next/headers.js");
31
- cookieStore = await cookies();
31
+ const { headers } = await import("next/headers.js");
32
+ headersStore = await headers();
32
33
  } catch {
33
34
  return;
34
35
  }
35
- try {
36
- cookieStore.set("__better-auth-cookie-store", "1", { maxAge: 0 });
37
- cookieStore.delete("__better-auth-cookie-store");
38
- } catch {
39
- await setShouldSkipSessionRefresh(true);
40
- }
36
+ /**
37
+ * Detect RSC via headers, NOT by probing cookies().set().
38
+ * In Next.js, cookies().set() unconditionally triggers router
39
+ * cache invalidation -- even if the value is unchanged.
40
+ *
41
+ * RSC sends `RSC: 1` without `next-action`. Only in that
42
+ * context cookies cannot be written -- skip session refresh
43
+ * to avoid DB/cookie mismatch.
44
+ *
45
+ * @see https://github.com/vercel/next.js/blob/8c5af211d580/packages/next/src/server/web/spec-extension/adapters/request-cookies.ts#L112-L157
46
+ */
47
+ const isRSC = headersStore.get("RSC") === "1";
48
+ const isServerAction = !!headersStore.get("next-action");
49
+ if (isRSC && !isServerAction) await setShouldSkipSessionRefresh(true);
41
50
  })
42
51
  }],
43
52
  after: [{
@@ -51,12 +60,12 @@ const nextCookies = () => {
51
60
  const setCookies = returned?.get("set-cookie");
52
61
  if (!setCookies) return;
53
62
  const parsed = parseSetCookieHeader(setCookies);
54
- const { cookies } = await import("next/headers.js");
55
63
  let cookieHelper;
56
64
  try {
65
+ const { cookies } = await import("next/headers.js");
57
66
  cookieHelper = await cookies();
58
67
  } catch (error) {
59
- if (error instanceof Error && error.message.startsWith("`cookies` was called outside a request scope.")) return;
68
+ if (error instanceof Error && (error.message.startsWith("`cookies` was called outside a request scope.") || error.message.includes("Cannot find module"))) return;
60
69
  throw error;
61
70
  }
62
71
  parsed.forEach((value, key) => {
@@ -15,6 +15,7 @@ declare function parseState(c: GenericEndpointContext): Promise<{
15
15
  expiresAt: number;
16
16
  errorURL?: string | undefined;
17
17
  newUserURL?: string | undefined;
18
+ oauthState?: string | undefined;
18
19
  link?: {
19
20
  email: string;
20
21
  userId: string;
package/dist/package.mjs CHANGED
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "1.6.1";
2
+ var version = "1.7.0-beta.0";
3
3
  //#endregion
4
4
  export { version };
@@ -775,11 +775,9 @@ declare const admin: <O extends AdminOptions>(options?: O | undefined) => {
775
775
  body: zod.ZodIntersection<zod.ZodObject<{
776
776
  userId: zod.ZodOptional<zod.ZodCoercedString<unknown>>;
777
777
  role: zod.ZodOptional<zod.ZodString>;
778
- }, zod_v4_core0.$strip>, zod.ZodUnion<readonly [zod.ZodObject<{
778
+ }, zod_v4_core0.$strip>, zod.ZodXor<readonly [zod.ZodObject<{
779
779
  permission: zod.ZodRecord<zod.ZodString, zod.ZodArray<zod.ZodString>>;
780
- permissions: zod.ZodUndefined;
781
780
  }, zod_v4_core0.$strip>, zod.ZodObject<{
782
- permission: zod.ZodUndefined;
783
781
  permissions: zod.ZodRecord<zod.ZodString, zod.ZodArray<zod.ZodString>>;
784
782
  }, zod_v4_core0.$strip>]>>;
785
783
  metadata: {
@@ -766,13 +766,7 @@ const setUserPassword = (opts) => createAuthEndpoint("/admin/set-user-password",
766
766
  const userHasPermissionBodySchema = z.object({
767
767
  userId: z.coerce.string().optional().meta({ description: `The user id. Eg: "user-id"` }),
768
768
  role: z.string().optional().meta({ description: `The role to check permission for. Eg: "admin"` })
769
- }).and(z.union([z.object({
770
- permission: z.record(z.string(), z.array(z.string())),
771
- permissions: z.undefined()
772
- }), z.object({
773
- permission: z.undefined(),
774
- permissions: z.record(z.string(), z.array(z.string()))
775
- })]));
769
+ }).and(z.xor([z.object({ permission: z.record(z.string(), z.array(z.string())) }), z.object({ permissions: z.record(z.string(), z.array(z.string())) })]));
776
770
  /**
777
771
  * ### Endpoint
778
772
  *
@@ -14,6 +14,13 @@ import { APIError } from "@better-auth/core/error";
14
14
  import { createAuthorizationURL, refreshAccessToken, validateAuthorizationCode } from "@better-auth/core/oauth2";
15
15
  import { betterFetch } from "@better-fetch/fetch";
16
16
  //#region src/plugins/generic-oauth/index.ts
17
+ function buildClientAssertion(config, tokenEndpoint) {
18
+ if (config.authentication !== "private_key_jwt" || !config.clientAssertion) return;
19
+ return {
20
+ ...config.clientAssertion,
21
+ tokenEndpoint
22
+ };
23
+ }
17
24
  /**
18
25
  * A generic OAuth plugin that can be used to add OAuth support to any provider
19
26
  */
@@ -87,7 +94,8 @@ const genericOAuth = (options) => {
87
94
  redirectURI: c.redirectURI
88
95
  },
89
96
  tokenEndpoint: finalTokenUrl,
90
- authentication: c.authentication
97
+ authentication: c.authentication,
98
+ clientAssertion: buildClientAssertion(c, finalTokenUrl)
91
99
  });
92
100
  },
93
101
  async refreshAccessToken(refreshToken) {
@@ -107,6 +115,7 @@ const genericOAuth = (options) => {
107
115
  clientSecret: c.clientSecret
108
116
  },
109
117
  authentication: c.authentication,
118
+ clientAssertion: buildClientAssertion(c, finalTokenUrl),
110
119
  tokenEndpoint: finalTokenUrl
111
120
  });
112
121
  },
@@ -195,6 +195,10 @@ const oAuth2Callback = (options) => createAuthEndpoint("/oauth2/callback/:provid
195
195
  },
196
196
  tokenEndpoint: finalTokenUrl,
197
197
  authentication: providerConfig.authentication,
198
+ clientAssertion: providerConfig.authentication === "private_key_jwt" && providerConfig.clientAssertion ? {
199
+ ...providerConfig.clientAssertion,
200
+ tokenEndpoint: finalTokenUrl
201
+ } : void 0,
198
202
  additionalParams
199
203
  });
200
204
  }
@@ -1,6 +1,6 @@
1
1
  import { GenericEndpointContext } from "@better-auth/core";
2
2
  import { User } from "@better-auth/core/db";
3
- import { OAuth2Tokens, OAuth2UserInfo } from "@better-auth/core/oauth2";
3
+ import { ClientAssertionConfig, OAuth2Tokens, OAuth2UserInfo } from "@better-auth/core/oauth2";
4
4
 
5
5
  //#region src/plugins/generic-oauth/types.d.ts
6
6
  interface GenericOAuthOptions {
@@ -134,7 +134,12 @@ interface GenericOAuthConfig {
134
134
  * Authentication method for token requests.
135
135
  * @default "post"
136
136
  */
137
- authentication?: ("basic" | "post") | undefined;
137
+ authentication?: ("basic" | "post" | "private_key_jwt") | undefined;
138
+ /**
139
+ * Client assertion config for `private_key_jwt` authentication.
140
+ * Required when `authentication` is `"private_key_jwt"`.
141
+ */
142
+ clientAssertion?: ClientAssertionConfig | undefined;
138
143
  /**
139
144
  * Custom headers to include in the discovery request.
140
145
  * Useful for providers like Epic that require specific headers (e.g., Epic-Client-ID).
@@ -16,7 +16,7 @@ declare function toExpJWT(expirationTime: number | Date | string, iat: number):
16
16
  declare function generateExportedKeyPair(options?: JwtOptions | undefined): Promise<{
17
17
  publicWebKey: jose.JWK;
18
18
  privateWebKey: jose.JWK;
19
- alg: "EdDSA" | "ES256" | "ES512" | "PS256" | "RS256";
19
+ alg: "RS256" | "PS256" | "ES256" | "ES512" | "EdDSA";
20
20
  cfg: {
21
21
  crv?: "Ed25519" | undefined;
22
22
  } | {
@@ -164,6 +164,10 @@ const oAuthProxy = (opts) => {
164
164
  return;
165
165
  }
166
166
  const errorURL = stateData.errorURL || ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`;
167
+ if (stateData.oauthState !== void 0 && stateData.oauthState !== statePackage.state) {
168
+ ctx.context.logger.error("OAuth proxy state binding mismatch");
169
+ throw redirectOnError(ctx, errorURL, "state_mismatch");
170
+ }
167
171
  if (error) throw redirectOnError(ctx, errorURL, error);
168
172
  if (!code) {
169
173
  ctx.context.logger.error("OAuth callback missing authorization code");
@@ -99,11 +99,9 @@ declare const createHasPermission: <O extends OrganizationOptions>(options: O) =
99
99
  requireHeaders: true;
100
100
  body: z.ZodIntersection<z.ZodObject<{
101
101
  organizationId: z.ZodOptional<z.ZodString>;
102
- }, z.core.$strip>, z.ZodUnion<readonly [z.ZodObject<{
102
+ }, z.core.$strip>, z.ZodXor<readonly [z.ZodObject<{
103
103
  permission: z.ZodRecord<z.ZodString, z.ZodArray<z.ZodString>>;
104
- permissions: z.ZodUndefined;
105
104
  }, z.core.$strip>, z.ZodObject<{
106
- permission: z.ZodUndefined;
107
105
  permissions: z.ZodRecord<z.ZodString, z.ZodArray<z.ZodString>>;
108
106
  }, z.core.$strip>]>>;
109
107
  use: ((inputContext: better_call0.MiddlewareInputContext<{
@@ -18,13 +18,7 @@ import * as z from "zod";
18
18
  function parseRoles(roles) {
19
19
  return Array.isArray(roles) ? roles.join(",") : roles;
20
20
  }
21
- const createHasPermissionBodySchema = z.object({ organizationId: z.string().optional() }).and(z.union([z.object({
22
- permission: z.record(z.string(), z.array(z.string())),
23
- permissions: z.undefined()
24
- }), z.object({
25
- permission: z.undefined(),
26
- permissions: z.record(z.string(), z.array(z.string()))
27
- })]));
21
+ const createHasPermissionBodySchema = z.object({ organizationId: z.string().optional() }).and(z.xor([z.object({ permission: z.record(z.string(), z.array(z.string())) }), z.object({ permissions: z.record(z.string(), z.array(z.string())) })]));
28
22
  const createHasPermission = (options) => {
29
23
  return createAuthEndpoint("/organization/has-permission", {
30
24
  method: "POST",
@@ -19,8 +19,18 @@ declare const twoFactorClient: (options?: {
19
19
  /**
20
20
  * a redirect function to call if a user needs to verify
21
21
  * their two factor
22
+ *
23
+ * @param context.twoFactorMethods - The list of
24
+ * enabled two factor providers (e.g. ["totp", "otp"]).
25
+ * Use this to determine which 2FA UI to show.
22
26
  */
23
- onTwoFactorRedirect?: () => void | Promise<void>;
27
+ onTwoFactorRedirect?: (context: {
28
+ /**
29
+ * The list of enabled two factor providers
30
+ * for the user (e.g. ["totp", "otp"]).
31
+ */
32
+ twoFactorMethods?: string[];
33
+ }) => void | Promise<void>;
24
34
  } | undefined) => {
25
35
  id: "two-factor";
26
36
  version: string;
@@ -48,8 +58,10 @@ declare const twoFactorClient: (options?: {
48
58
  }[];
49
59
  $ERROR_CODES: {
50
60
  OTP_NOT_ENABLED: _better_auth_core_utils_error_codes0.RawError<"OTP_NOT_ENABLED">;
61
+ OTP_NOT_CONFIGURED: _better_auth_core_utils_error_codes0.RawError<"OTP_NOT_CONFIGURED">;
51
62
  OTP_HAS_EXPIRED: _better_auth_core_utils_error_codes0.RawError<"OTP_HAS_EXPIRED">;
52
63
  TOTP_NOT_ENABLED: _better_auth_core_utils_error_codes0.RawError<"TOTP_NOT_ENABLED">;
64
+ TOTP_NOT_CONFIGURED: _better_auth_core_utils_error_codes0.RawError<"TOTP_NOT_CONFIGURED">;
53
65
  TWO_FACTOR_NOT_ENABLED: _better_auth_core_utils_error_codes0.RawError<"TWO_FACTOR_NOT_ENABLED">;
54
66
  BACKUP_CODES_NOT_ENABLED: _better_auth_core_utils_error_codes0.RawError<"BACKUP_CODES_NOT_ENABLED">;
55
67
  INVALID_BACKUP_CODE: _better_auth_core_utils_error_codes0.RawError<"INVALID_BACKUP_CODE">;
@@ -26,7 +26,7 @@ const twoFactorClient = (options) => {
26
26
  hooks: { async onSuccess(context) {
27
27
  if (context.data?.twoFactorRedirect) {
28
28
  if (options?.onTwoFactorRedirect) {
29
- await options.onTwoFactorRedirect();
29
+ await options.onTwoFactorRedirect({ twoFactorMethods: context.data.twoFactorMethods });
30
30
  return;
31
31
  }
32
32
  if (options?.twoFactorPage && typeof window !== "undefined") window.location.href = options.twoFactorPage;
@@ -3,8 +3,10 @@ import * as _better_auth_core_utils_error_codes0 from "@better-auth/core/utils/e
3
3
  //#region src/plugins/two-factor/error-code.d.ts
4
4
  declare const TWO_FACTOR_ERROR_CODES: {
5
5
  OTP_NOT_ENABLED: _better_auth_core_utils_error_codes0.RawError<"OTP_NOT_ENABLED">;
6
+ OTP_NOT_CONFIGURED: _better_auth_core_utils_error_codes0.RawError<"OTP_NOT_CONFIGURED">;
6
7
  OTP_HAS_EXPIRED: _better_auth_core_utils_error_codes0.RawError<"OTP_HAS_EXPIRED">;
7
8
  TOTP_NOT_ENABLED: _better_auth_core_utils_error_codes0.RawError<"TOTP_NOT_ENABLED">;
9
+ TOTP_NOT_CONFIGURED: _better_auth_core_utils_error_codes0.RawError<"TOTP_NOT_CONFIGURED">;
8
10
  TWO_FACTOR_NOT_ENABLED: _better_auth_core_utils_error_codes0.RawError<"TWO_FACTOR_NOT_ENABLED">;
9
11
  BACKUP_CODES_NOT_ENABLED: _better_auth_core_utils_error_codes0.RawError<"BACKUP_CODES_NOT_ENABLED">;
10
12
  INVALID_BACKUP_CODE: _better_auth_core_utils_error_codes0.RawError<"INVALID_BACKUP_CODE">;
@@ -2,8 +2,10 @@ import { defineErrorCodes } from "@better-auth/core/utils/error-codes";
2
2
  //#region src/plugins/two-factor/error-code.ts
3
3
  const TWO_FACTOR_ERROR_CODES = defineErrorCodes({
4
4
  OTP_NOT_ENABLED: "OTP not enabled",
5
+ OTP_NOT_CONFIGURED: "OTP is not available",
5
6
  OTP_HAS_EXPIRED: "OTP has expired",
6
7
  TOTP_NOT_ENABLED: "TOTP not enabled",
8
+ TOTP_NOT_CONFIGURED: "TOTP is not available",
7
9
  TWO_FACTOR_NOT_ENABLED: "Two factor isn't enabled",
8
10
  BACKUP_CODES_NOT_ENABLED: "Backup codes aren't enabled",
9
11
  INVALID_BACKUP_CODE: "Invalid backup code",
@@ -40,9 +40,17 @@ declare const twoFactor: <O extends TwoFactorOptions>(options?: O) => {
40
40
  method: "POST";
41
41
  body: z.ZodObject<{
42
42
  password: z.ZodOptional<z.ZodString>;
43
+ method: z.ZodDefault<z.ZodEnum<{
44
+ otp: "otp";
45
+ totp: "totp";
46
+ }>>;
43
47
  issuer: z.ZodOptional<z.ZodString>;
44
48
  }, z.core.$strip> | z.ZodObject<{
45
49
  password: z.ZodString;
50
+ method: z.ZodDefault<z.ZodEnum<{
51
+ otp: "otp";
52
+ totp: "totp";
53
+ }>>;
46
54
  issuer: z.ZodOptional<z.ZodString>;
47
55
  }, z.core.$strip>;
48
56
  use: ((inputContext: better_call0.MiddlewareInputContext<better_call0.MiddlewareOptions>) => Promise<{
@@ -80,6 +88,11 @@ declare const twoFactor: <O extends TwoFactorOptions>(options?: O) => {
80
88
  schema: {
81
89
  type: "object";
82
90
  properties: {
91
+ method: {
92
+ type: string;
93
+ enum: string[];
94
+ description: string;
95
+ };
83
96
  totpURI: {
84
97
  type: string;
85
98
  description: string;
@@ -92,6 +105,7 @@ declare const twoFactor: <O extends TwoFactorOptions>(options?: O) => {
92
105
  description: string;
93
106
  };
94
107
  };
108
+ required: string[];
95
109
  };
96
110
  };
97
111
  };
@@ -100,6 +114,9 @@ declare const twoFactor: <O extends TwoFactorOptions>(options?: O) => {
100
114
  };
101
115
  };
102
116
  }, {
117
+ method: "otp";
118
+ } | {
119
+ method: "totp";
103
120
  totpURI: string;
104
121
  backupCodes: string[];
105
122
  }>;
@@ -618,6 +635,7 @@ declare const twoFactor: <O extends TwoFactorOptions>(options?: O) => {
618
635
  matcher(context: _better_auth_core0.HookEndpointContext): boolean;
619
636
  handler: (inputContext: better_call0.MiddlewareInputContext<better_call0.MiddlewareOptions>) => Promise<{
620
637
  twoFactorRedirect: boolean;
638
+ twoFactorMethods: string[];
621
639
  } | undefined>;
622
640
  }[];
623
641
  };
@@ -655,6 +673,12 @@ declare const twoFactor: <O extends TwoFactorOptions>(options?: O) => {
655
673
  };
656
674
  index: true;
657
675
  };
676
+ verified: {
677
+ type: "boolean";
678
+ required: false;
679
+ defaultValue: true;
680
+ input: false;
681
+ };
658
682
  };
659
683
  };
660
684
  };
@@ -665,8 +689,10 @@ declare const twoFactor: <O extends TwoFactorOptions>(options?: O) => {
665
689
  }[];
666
690
  $ERROR_CODES: {
667
691
  OTP_NOT_ENABLED: _better_auth_core_utils_error_codes0.RawError<"OTP_NOT_ENABLED">;
692
+ OTP_NOT_CONFIGURED: _better_auth_core_utils_error_codes0.RawError<"OTP_NOT_CONFIGURED">;
668
693
  OTP_HAS_EXPIRED: _better_auth_core_utils_error_codes0.RawError<"OTP_HAS_EXPIRED">;
669
694
  TOTP_NOT_ENABLED: _better_auth_core_utils_error_codes0.RawError<"TOTP_NOT_ENABLED">;
695
+ TOTP_NOT_CONFIGURED: _better_auth_core_utils_error_codes0.RawError<"TOTP_NOT_CONFIGURED">;
670
696
  TWO_FACTOR_NOT_ENABLED: _better_auth_core_utils_error_codes0.RawError<"TWO_FACTOR_NOT_ENABLED">;
671
697
  BACKUP_CODES_NOT_ENABLED: _better_auth_core_utils_error_codes0.RawError<"BACKUP_CODES_NOT_ENABLED">;
672
698
  INVALID_BACKUP_CODE: _better_auth_core_utils_error_codes0.RawError<"INVALID_BACKUP_CODE">;
@@ -36,12 +36,16 @@ const twoFactor = (options) => {
36
36
  });
37
37
  const otp = otp2fa(options?.otpOptions);
38
38
  const passwordSchema = z.string().meta({ description: "User password" });
39
+ const methodField = z.enum(["otp", "totp"]).default("totp").meta({ description: "The 2FA method to enable. 'totp' generates an authenticator app secret (requires verification). 'otp' enables email/SMS-based codes immediately." });
40
+ const issuerField = z.string().meta({ description: "Custom issuer for the TOTP URI" }).optional();
39
41
  const enableTwoFactorBodySchema = allowPasswordless ? z.object({
40
42
  password: passwordSchema.optional(),
41
- issuer: z.string().meta({ description: "Custom issuer for the TOTP URI" }).optional()
43
+ method: methodField,
44
+ issuer: issuerField
42
45
  }) : z.object({
43
46
  password: passwordSchema,
44
- issuer: z.string().meta({ description: "Custom issuer for the TOTP URI" }).optional()
47
+ method: methodField,
48
+ issuer: issuerField
45
49
  });
46
50
  const disableTwoFactorBodySchema = allowPasswordless ? z.object({ password: passwordSchema.optional() }) : z.object({ password: passwordSchema });
47
51
  return {
@@ -57,28 +61,34 @@ const twoFactor = (options) => {
57
61
  use: [sessionMiddleware],
58
62
  metadata: { openapi: {
59
63
  summary: "Enable two factor authentication",
60
- description: "Use this endpoint to enable two factor authentication. This will generate a TOTP URI and backup codes. Once the user verifies the TOTP URI, the two factor authentication will be enabled.",
64
+ description: "Enable two factor authentication. Pass method 'totp' (default) to set up an authenticator app (returns TOTP URI and backup codes), or 'otp' to enable email/SMS-based codes immediately.",
61
65
  responses: { 200: {
62
66
  description: "Successful response",
63
67
  content: { "application/json": { schema: {
64
68
  type: "object",
65
69
  properties: {
70
+ method: {
71
+ type: "string",
72
+ enum: ["otp", "totp"],
73
+ description: "The 2FA method that was enabled."
74
+ },
66
75
  totpURI: {
67
76
  type: "string",
68
- description: "TOTP URI"
77
+ description: "TOTP URI for authenticator app setup. Only present when method is 'totp'."
69
78
  },
70
79
  backupCodes: {
71
80
  type: "array",
72
81
  items: { type: "string" },
73
- description: "Backup codes"
82
+ description: "Recovery backup codes. Only present when method is 'totp'."
74
83
  }
75
- }
84
+ },
85
+ required: ["method"]
76
86
  } } }
77
87
  } }
78
88
  } }
79
89
  }, async (ctx) => {
80
90
  const user = ctx.context.session.user;
81
- const { password, issuer } = ctx.body;
91
+ const { password, issuer, method } = ctx.body;
82
92
  if (await shouldRequirePassword(ctx, user.id, allowPasswordless)) {
83
93
  if (!password) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.INVALID_PASSWORD);
84
94
  if (!await validatePassword(ctx, {
@@ -86,35 +96,46 @@ const twoFactor = (options) => {
86
96
  userId: user.id
87
97
  })) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.INVALID_PASSWORD);
88
98
  }
89
- const secret = generateRandomString(32);
90
- const encryptedSecret = await symmetricEncrypt({
91
- key: ctx.context.secretConfig,
92
- data: secret
93
- });
94
- const backupCodes = await generateBackupCodes(ctx.context.secretConfig, backupCodeOptions);
95
- if (options?.skipVerificationOnEnable) {
99
+ if (method === "otp" && !options?.otpOptions?.sendOTP) throw APIError.from("BAD_REQUEST", TWO_FACTOR_ERROR_CODES.OTP_NOT_CONFIGURED);
100
+ if (method === "totp" && options?.totpOptions?.disable) throw APIError.from("BAD_REQUEST", TWO_FACTOR_ERROR_CODES.TOTP_NOT_CONFIGURED);
101
+ if (method === "otp") {
96
102
  const updatedUser = await ctx.context.internalAdapter.updateUser(user.id, { twoFactorEnabled: true });
97
- /**
98
- * Update the session cookie with the new user data
99
- */
100
103
  await setSessionCookie(ctx, {
101
104
  session: await ctx.context.internalAdapter.createSession(updatedUser.id, false, ctx.context.session.session),
102
105
  user: updatedUser
103
106
  });
104
107
  await ctx.context.internalAdapter.deleteSession(ctx.context.session.session.token);
108
+ return ctx.json({ method: "otp" });
105
109
  }
106
- await ctx.context.adapter.deleteMany({
110
+ const backupCodes = await generateBackupCodes(ctx.context.secretConfig, backupCodeOptions);
111
+ const existingTwoFactor = await ctx.context.adapter.findOne({
107
112
  model: opts.twoFactorTable,
108
113
  where: [{
109
114
  field: "userId",
110
115
  value: user.id
111
116
  }]
112
117
  });
113
- await ctx.context.adapter.create({
118
+ const secret = generateRandomString(32);
119
+ const totpData = {
120
+ secret: await symmetricEncrypt({
121
+ key: ctx.context.secretConfig,
122
+ data: secret
123
+ }),
124
+ backupCodes: backupCodes.encryptedBackupCodes,
125
+ verified: existingTwoFactor != null && existingTwoFactor.verified === true
126
+ };
127
+ if (existingTwoFactor) await ctx.context.adapter.update({
128
+ model: opts.twoFactorTable,
129
+ update: totpData,
130
+ where: [{
131
+ field: "id",
132
+ value: existingTwoFactor.id
133
+ }]
134
+ });
135
+ else await ctx.context.adapter.create({
114
136
  model: opts.twoFactorTable,
115
137
  data: {
116
- secret: encryptedSecret,
117
- backupCodes: backupCodes.encryptedBackupCodes,
138
+ ...totpData,
118
139
  userId: user.id
119
140
  }
120
141
  });
@@ -123,6 +144,7 @@ const twoFactor = (options) => {
123
144
  period: options?.totpOptions?.period
124
145
  }).url(issuer || options?.issuer || ctx.context.appName, user.email);
125
146
  return ctx.json({
147
+ method: "totp",
126
148
  totpURI,
127
149
  backupCodes: backupCodes.backupCodes
128
150
  });
@@ -225,7 +247,30 @@ const twoFactor = (options) => {
225
247
  expiresAt: new Date(Date.now() + maxAge * 1e3)
226
248
  });
227
249
  await ctx.setSignedCookie(twoFactorCookie.name, identifier, ctx.context.secret, twoFactorCookie.attributes);
228
- return ctx.json({ twoFactorRedirect: true });
250
+ const twoFactorMethods = [];
251
+ /**
252
+ * totp requires per-user setup, so we check
253
+ * that the user actually has a secret stored.
254
+ */
255
+ if (!options?.totpOptions?.disable) {
256
+ const userTotpSecret = await ctx.context.adapter.findOne({
257
+ model: opts.twoFactorTable,
258
+ where: [{
259
+ field: "userId",
260
+ value: data.user.id
261
+ }]
262
+ });
263
+ if (userTotpSecret && userTotpSecret.verified !== false) twoFactorMethods.push("totp");
264
+ }
265
+ /**
266
+ * otp is server-level — if sendOTP is configured,
267
+ * any user with 2fa enabled can receive a code.
268
+ */
269
+ if (options?.otpOptions?.sendOTP) twoFactorMethods.push("otp");
270
+ return ctx.json({
271
+ twoFactorRedirect: true,
272
+ twoFactorMethods
273
+ });
229
274
  })
230
275
  }] },
231
276
  schema: mergeSchema(schema, {
@@ -175,11 +175,11 @@ const otp2fa = (options) => {
175
175
  if (!session.session) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.FAILED_TO_CREATE_SESSION);
176
176
  const updatedUser = await ctx.context.internalAdapter.updateUser(session.user.id, { twoFactorEnabled: true });
177
177
  const newSession = await ctx.context.internalAdapter.createSession(session.user.id, false, session.session);
178
- await ctx.context.internalAdapter.deleteSession(session.session.token);
179
178
  await setSessionCookie(ctx, {
180
179
  session: newSession,
181
180
  user: updatedUser
182
181
  });
182
+ await ctx.context.internalAdapter.deleteSession(session.session.token);
183
183
  return ctx.json({
184
184
  token: newSession.token,
185
185
  user: parseUserOutput(ctx.context.options, updatedUser)
@@ -33,6 +33,12 @@ declare const schema: {
33
33
  };
34
34
  index: true;
35
35
  };
36
+ verified: {
37
+ type: "boolean";
38
+ required: false;
39
+ defaultValue: true;
40
+ input: false;
41
+ };
36
42
  };
37
43
  };
38
44
  };
@@ -27,6 +27,12 @@ const schema = {
27
27
  field: "id"
28
28
  },
29
29
  index: true
30
+ },
31
+ verified: {
32
+ type: "boolean",
33
+ required: false,
34
+ defaultValue: true,
35
+ input: false
30
36
  }
31
37
  } }
32
38
  };
@@ -124,6 +124,7 @@ const totp2fa = (options) => {
124
124
  }
125
125
  const { session, valid, invalid } = await verifyTwoFactor(ctx);
126
126
  const user = session.user;
127
+ const isSignIn = !session.session;
127
128
  const twoFactor = await ctx.context.adapter.findOne({
128
129
  model: twoFactorTable,
129
130
  where: [{
@@ -132,6 +133,7 @@ const totp2fa = (options) => {
132
133
  }]
133
134
  });
134
135
  if (!twoFactor) throw APIError.from("BAD_REQUEST", TWO_FACTOR_ERROR_CODES.TOTP_NOT_ENABLED);
136
+ if (isSignIn && twoFactor.verified === false) throw APIError.from("BAD_REQUEST", TWO_FACTOR_ERROR_CODES.TOTP_NOT_ENABLED);
135
137
  if (!await createOTP(await symmetricDecrypt({
136
138
  key: ctx.context.secretConfig,
137
139
  data: twoFactor.secret
@@ -139,16 +141,23 @@ const totp2fa = (options) => {
139
141
  period: opts.period,
140
142
  digits: opts.digits
141
143
  }).verify(ctx.body.code)) return invalid("INVALID_CODE");
142
- if (!user.twoFactorEnabled) {
143
- if (!session.session) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.FAILED_TO_CREATE_SESSION);
144
- const updatedUser = await ctx.context.internalAdapter.updateUser(user.id, { twoFactorEnabled: true });
145
- const newSession = await ctx.context.internalAdapter.createSession(user.id, false, session.session).catch((e) => {
146
- throw e;
147
- });
148
- await ctx.context.internalAdapter.deleteSession(session.session.token);
149
- await setSessionCookie(ctx, {
150
- session: newSession,
151
- user: updatedUser
144
+ if (twoFactor.verified !== true) {
145
+ if (!user.twoFactorEnabled) {
146
+ const activeSession = session.session;
147
+ const updatedUser = await ctx.context.internalAdapter.updateUser(user.id, { twoFactorEnabled: true });
148
+ await setSessionCookie(ctx, {
149
+ session: await ctx.context.internalAdapter.createSession(user.id, false, activeSession),
150
+ user: updatedUser
151
+ });
152
+ await ctx.context.internalAdapter.deleteSession(activeSession.token);
153
+ }
154
+ await ctx.context.adapter.update({
155
+ model: twoFactorTable,
156
+ update: { verified: true },
157
+ where: [{
158
+ field: "id",
159
+ value: twoFactor.id
160
+ }]
152
161
  });
153
162
  }
154
163
  return valid(ctx);
@@ -31,11 +31,6 @@ interface TwoFactorOptions {
31
31
  * Backup code options
32
32
  */
33
33
  backupCodeOptions?: BackupCodeOptions | undefined;
34
- /**
35
- * Skip verification on enabling two factor authentication.
36
- * @default false
37
- */
38
- skipVerificationOnEnable?: boolean | undefined;
39
34
  /**
40
35
  * Allow enabling and managing 2FA without a password when the user does not
41
36
  * have a credential account (e.g. passkey-only users).
@@ -80,7 +75,7 @@ interface TwoFactorTable {
80
75
  userId: string;
81
76
  secret: string;
82
77
  backupCodes: string;
83
- enabled: boolean;
78
+ verified: boolean;
84
79
  }
85
80
  //#endregion
86
81
  export { TwoFactorOptions, TwoFactorProvider, TwoFactorTable, UserWithTwoFactor };
package/dist/state.d.mts CHANGED
@@ -9,6 +9,11 @@ declare const stateDataSchema: z.ZodObject<{
9
9
  errorURL: z.ZodOptional<z.ZodString>;
10
10
  newUserURL: z.ZodOptional<z.ZodString>;
11
11
  expiresAt: z.ZodNumber;
12
+ /**
13
+ * CSRF nonce returned to the OAuth provider. When using cookie state storage,
14
+ * this must match the callback `state` query parameter.
15
+ */
16
+ oauthState: z.ZodOptional<z.ZodString>;
12
17
  link: z.ZodOptional<z.ZodObject<{
13
18
  email: z.ZodString;
14
19
  userId: z.ZodCoercedString<unknown>;
@@ -32,6 +37,7 @@ declare function parseGenericState(c: GenericEndpointContext, state: string, set
32
37
  expiresAt: number;
33
38
  errorURL?: string | undefined;
34
39
  newUserURL?: string | undefined;
40
+ oauthState?: string | undefined;
35
41
  link?: {
36
42
  email: string;
37
43
  userId: string;
package/dist/state.mjs CHANGED
@@ -10,6 +10,7 @@ const stateDataSchema = z.looseObject({
10
10
  errorURL: z.string().optional(),
11
11
  newUserURL: z.string().optional(),
12
12
  expiresAt: z.number(),
13
+ oauthState: z.string().optional(),
13
14
  link: z.object({
14
15
  email: z.string(),
15
16
  userId: z.coerce.string()
@@ -28,9 +29,13 @@ var StateError = class extends BetterAuthError {
28
29
  async function generateGenericState(c, stateData, settings) {
29
30
  const state = generateRandomString(32);
30
31
  if (c.context.oauthConfig.storeStateStrategy === "cookie") {
32
+ const payload = {
33
+ ...stateData,
34
+ oauthState: state
35
+ };
31
36
  const encryptedData = await symmetricEncrypt({
32
37
  key: c.context.secretConfig,
33
- data: JSON.stringify(stateData)
38
+ data: JSON.stringify(payload)
34
39
  });
35
40
  const stateCookie = c.context.createAuthCookie(settings?.cookieName ?? "oauth_state", { maxAge: 600 });
36
41
  c.setCookie(stateCookie.name, encryptedData, stateCookie.attributes);
@@ -44,7 +49,10 @@ async function generateGenericState(c, stateData, settings) {
44
49
  const expiresAt = /* @__PURE__ */ new Date();
45
50
  expiresAt.setMinutes(expiresAt.getMinutes() + 10);
46
51
  if (!await c.context.internalAdapter.createVerificationValue({
47
- value: JSON.stringify(stateData),
52
+ value: JSON.stringify({
53
+ ...stateData,
54
+ oauthState: state
55
+ }),
48
56
  identifier: state,
49
57
  expiresAt
50
58
  })) throw new StateError("Unable to create verification. Make sure the database adapter is properly working and there is a verification table in the database", { code: "state_generation_error" });
@@ -76,6 +84,10 @@ async function parseGenericState(c, state, settings) {
76
84
  cause: error
77
85
  });
78
86
  }
87
+ if (!parsedData.oauthState || parsedData.oauthState !== state) throw new StateError("State mismatch: OAuth state parameter does not match stored state", {
88
+ code: "state_security_mismatch",
89
+ details: { state }
90
+ });
79
91
  expireCookie(c, stateCookie);
80
92
  } else {
81
93
  const data = await c.context.internalAdapter.findVerificationValue(state);
@@ -84,6 +96,10 @@ async function parseGenericState(c, state, settings) {
84
96
  details: { state }
85
97
  });
86
98
  parsedData = stateDataSchema.parse(JSON.parse(data.value));
99
+ if (parsedData.oauthState !== void 0 && parsedData.oauthState !== state) throw new StateError("State mismatch: OAuth state parameter does not match stored state", {
100
+ code: "state_security_mismatch",
101
+ details: { state }
102
+ });
87
103
  const stateCookie = c.context.createAuthCookie(settings?.cookieName ?? "state");
88
104
  const stateCookieValue = await c.getSignedCookie(stateCookie.name, c.context.secret);
89
105
  if (!(settings?.skipStateCookieCheck ?? c.context.oauthConfig.skipStateCookieCheck) && (!stateCookieValue || stateCookieValue !== state)) throw new StateError("State mismatch: State not persisted correctly", {
@@ -261,7 +261,6 @@ declare function getTestInstance<O extends Partial<BetterAuthOptions>, C extends
261
261
  "application/json": {
262
262
  schema: {
263
263
  type: "object";
264
- nullable: boolean;
265
264
  properties: {
266
265
  session: {
267
266
  $ref: string;
@@ -2265,7 +2264,6 @@ declare function getTestInstance<O extends Partial<BetterAuthOptions>, C extends
2265
2264
  "application/json": {
2266
2265
  schema: {
2267
2266
  type: "object";
2268
- nullable: boolean;
2269
2267
  properties: {
2270
2268
  session: {
2271
2269
  $ref: string;
@@ -4272,7 +4270,6 @@ declare function getTestInstance<O extends Partial<BetterAuthOptions>, C extends
4272
4270
  "application/json": {
4273
4271
  schema: {
4274
4272
  type: "object";
4275
- nullable: boolean;
4276
4273
  properties: {
4277
4274
  session: {
4278
4275
  $ref: string;
@@ -6276,7 +6273,6 @@ declare function getTestInstance<O extends Partial<BetterAuthOptions>, C extends
6276
6273
  "application/json": {
6277
6274
  schema: {
6278
6275
  type: "object";
6279
- nullable: boolean;
6280
6276
  properties: {
6281
6277
  session: {
6282
6278
  $ref: string;
@@ -8354,7 +8350,6 @@ declare function getTestInstance<O extends Partial<BetterAuthOptions>, C extends
8354
8350
  "application/json": {
8355
8351
  schema: {
8356
8352
  type: "object";
8357
- nullable: boolean;
8358
8353
  properties: {
8359
8354
  session: {
8360
8355
  $ref: string;
@@ -10358,7 +10353,6 @@ declare function getTestInstance<O extends Partial<BetterAuthOptions>, C extends
10358
10353
  "application/json": {
10359
10354
  schema: {
10360
10355
  type: "object";
10361
- nullable: boolean;
10362
10356
  properties: {
10363
10357
  session: {
10364
10358
  $ref: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "better-auth",
3
- "version": "1.6.1",
3
+ "version": "1.7.0-beta.0",
4
4
  "description": "The most comprehensive authentication framework for TypeScript.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -489,13 +489,13 @@
489
489
  "kysely": "^0.28.14",
490
490
  "nanostores": "^1.1.1",
491
491
  "zod": "^4.3.6",
492
- "@better-auth/core": "1.6.1",
493
- "@better-auth/drizzle-adapter": "1.6.1",
494
- "@better-auth/kysely-adapter": "1.6.1",
495
- "@better-auth/memory-adapter": "1.6.1",
496
- "@better-auth/mongo-adapter": "1.6.1",
497
- "@better-auth/prisma-adapter": "1.6.1",
498
- "@better-auth/telemetry": "1.6.1"
492
+ "@better-auth/core": "1.7.0-beta.0",
493
+ "@better-auth/drizzle-adapter": "1.7.0-beta.0",
494
+ "@better-auth/kysely-adapter": "1.7.0-beta.0",
495
+ "@better-auth/memory-adapter": "1.7.0-beta.0",
496
+ "@better-auth/mongo-adapter": "1.7.0-beta.0",
497
+ "@better-auth/prisma-adapter": "1.7.0-beta.0",
498
+ "@better-auth/telemetry": "1.7.0-beta.0"
499
499
  },
500
500
  "devDependencies": {
501
501
  "@lynx-js/react": "^0.116.3",