better-auth 1.6.1 → 1.6.2

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.
@@ -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({
@@ -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.6.2";
3
3
  //#endregion
4
4
  export { version };
@@ -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");
@@ -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;
@@ -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;
@@ -618,6 +618,7 @@ declare const twoFactor: <O extends TwoFactorOptions>(options?: O) => {
618
618
  matcher(context: _better_auth_core0.HookEndpointContext): boolean;
619
619
  handler: (inputContext: better_call0.MiddlewareInputContext<better_call0.MiddlewareOptions>) => Promise<{
620
620
  twoFactorRedirect: boolean;
621
+ twoFactorMethods: string[];
621
622
  } | undefined>;
622
623
  }[];
623
624
  };
@@ -655,6 +656,12 @@ declare const twoFactor: <O extends TwoFactorOptions>(options?: O) => {
655
656
  };
656
657
  index: true;
657
658
  };
659
+ verified: {
660
+ type: "boolean";
661
+ required: false;
662
+ defaultValue: true;
663
+ input: false;
664
+ };
658
665
  };
659
666
  };
660
667
  };
@@ -103,6 +103,13 @@ const twoFactor = (options) => {
103
103
  });
104
104
  await ctx.context.internalAdapter.deleteSession(ctx.context.session.session.token);
105
105
  }
106
+ const existingTwoFactor = await ctx.context.adapter.findOne({
107
+ model: opts.twoFactorTable,
108
+ where: [{
109
+ field: "userId",
110
+ value: user.id
111
+ }]
112
+ });
106
113
  await ctx.context.adapter.deleteMany({
107
114
  model: opts.twoFactorTable,
108
115
  where: [{
@@ -115,7 +122,8 @@ const twoFactor = (options) => {
115
122
  data: {
116
123
  secret: encryptedSecret,
117
124
  backupCodes: backupCodes.encryptedBackupCodes,
118
- userId: user.id
125
+ userId: user.id,
126
+ verified: existingTwoFactor != null && existingTwoFactor.verified !== false || !!options?.skipVerificationOnEnable
119
127
  }
120
128
  });
121
129
  const totpURI = createOTP(secret, {
@@ -225,7 +233,30 @@ const twoFactor = (options) => {
225
233
  expiresAt: new Date(Date.now() + maxAge * 1e3)
226
234
  });
227
235
  await ctx.setSignedCookie(twoFactorCookie.name, identifier, ctx.context.secret, twoFactorCookie.attributes);
228
- return ctx.json({ twoFactorRedirect: true });
236
+ const twoFactorMethods = [];
237
+ /**
238
+ * totp requires per-user setup, so we check
239
+ * that the user actually has a secret stored.
240
+ */
241
+ if (!options?.totpOptions?.disable) {
242
+ const userTotpSecret = await ctx.context.adapter.findOne({
243
+ model: opts.twoFactorTable,
244
+ where: [{
245
+ field: "userId",
246
+ value: data.user.id
247
+ }]
248
+ });
249
+ if (userTotpSecret && userTotpSecret.verified !== false) twoFactorMethods.push("totp");
250
+ }
251
+ /**
252
+ * otp is server-level — if sendOTP is configured,
253
+ * any user with 2fa enabled can receive a code.
254
+ */
255
+ if (options?.otpOptions?.sendOTP) twoFactorMethods.push("otp");
256
+ return ctx.json({
257
+ twoFactorRedirect: true,
258
+ twoFactorMethods
259
+ });
229
260
  })
230
261
  }] },
231
262
  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);
@@ -80,7 +80,7 @@ interface TwoFactorTable {
80
80
  userId: string;
81
81
  secret: string;
82
82
  backupCodes: string;
83
- enabled: boolean;
83
+ verified: boolean;
84
84
  }
85
85
  //#endregion
86
86
  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", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "better-auth",
3
- "version": "1.6.1",
3
+ "version": "1.6.2",
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.6.2",
493
+ "@better-auth/drizzle-adapter": "1.6.2",
494
+ "@better-auth/kysely-adapter": "1.6.2",
495
+ "@better-auth/memory-adapter": "1.6.2",
496
+ "@better-auth/mongo-adapter": "1.6.2",
497
+ "@better-auth/prisma-adapter": "1.6.2",
498
+ "@better-auth/telemetry": "1.6.2"
499
499
  },
500
500
  "devDependencies": {
501
501
  "@lynx-js/react": "^0.116.3",