better-auth 1.7.5 → 1.7.6

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.
@@ -1,14 +1,12 @@
1
1
  import { isAPIError } from "../../utils/is-api-error.mjs";
2
2
  import { setSessionCookie } from "../../cookies/index.mjs";
3
- import { assertValidUserInfo } from "../../utils/validate-user-info.mjs";
4
3
  import { getAwaitableValue } from "../../context/helpers.mjs";
5
4
  import { resolveOAuthAccountKey, toOAuthProfileRecord } from "../../oauth2/account-key.mjs";
6
5
  import { OAUTH_CALLBACK_ERROR_CODES, missingEmailLogMessage } from "../../oauth2/errors.mjs";
7
- import { getOAuthCallbackPath, setTokenUtil } from "../../oauth2/utils.mjs";
8
- import { applyUpdateUserInfoOnLink, handleOAuthUserInfo } from "../../oauth2/link-account.mjs";
6
+ import { getOAuthCallbackPath } from "../../oauth2/utils.mjs";
7
+ import { handleOAuthUserInfo, linkOAuthAccount } from "../../oauth2/link-account.mjs";
9
8
  import { generateIdTokenNonce, generateState, parseState } from "../../oauth2/state.mjs";
10
9
  import { HIDE_METADATA } from "../../utils/hide-metadata.mjs";
11
- import { mergeScopes } from "@better-auth/core/oauth2";
12
10
  import { safeJSONParse } from "@better-auth/core/utils/json";
13
11
  import { appendQueryParams } from "@better-auth/core/utils/url";
14
12
  import { createAuthEndpoint } from "@better-auth/core/api";
@@ -149,72 +147,26 @@ const callbackOAuth = createAuthEndpoint("/callback/:id", {
149
147
  c.context.logger.error("No callback URL found");
150
148
  throw redirectOnError(OAUTH_CALLBACK_ERROR_CODES.NO_CALLBACK_URL);
151
149
  }
150
+ const accountData = {
151
+ ...accountKey,
152
+ ...tokens,
153
+ scope: tokens.scopes?.join(",")
154
+ };
152
155
  if (link) {
153
- try {
154
- await assertValidUserInfo(c, {
155
- user: {
156
- ...userInfo,
157
- id: link.userId,
158
- email: userInfo.email ?? void 0
159
- },
160
- source: {
161
- action: "link-account",
162
- method: "oauth",
163
- oauth: {
164
- providerId: provider.id,
165
- profile: providerProfile
166
- }
167
- }
168
- });
169
- } catch (e) {
170
- if (isAPIError(e) && e.body?.code) throw redirectOnError(e.body.code, e.body.message);
171
- throw e;
172
- }
173
- if (!c.context.trustedProviders.includes(provider.id) && !userInfo.emailVerified || c.context.options.account?.accountLinking?.enabled === false) {
174
- c.context.logger.error("Unable to link account - untrusted provider");
175
- return redirectOnError(OAUTH_CALLBACK_ERROR_CODES.UNABLE_TO_LINK_ACCOUNT);
176
- }
177
- if (userInfo.email?.toLowerCase() !== link.email.toLowerCase() && c.context.options.account?.accountLinking?.allowDifferentEmails !== true) return redirectOnError(OAUTH_CALLBACK_ERROR_CODES.EMAIL_DOES_NOT_MATCH);
178
- const existingAccount = await c.context.internalAdapter.findAccountByKey(accountKey);
179
- if (existingAccount) {
180
- if (existingAccount.userId.toString() !== link.userId.toString()) return redirectOnError(OAUTH_CALLBACK_ERROR_CODES.ACCOUNT_ALREADY_LINKED_TO_DIFFERENT_USER);
181
- const mergedScope = mergeScopes(existingAccount.scope, tokens.scopes);
182
- const updateData = Object.fromEntries(Object.entries({
183
- providerId: provider.id,
184
- accessToken: await setTokenUtil(tokens.accessToken, c.context),
185
- refreshToken: await setTokenUtil(tokens.refreshToken, c.context),
186
- idToken: tokens.idToken,
187
- accessTokenExpiresAt: tokens.accessTokenExpiresAt,
188
- refreshTokenExpiresAt: tokens.refreshTokenExpiresAt,
189
- scope: mergedScope || void 0
190
- }).filter(([_, value]) => value !== void 0));
191
- await c.context.internalAdapter.updateAccount(existingAccount.id, updateData);
192
- } else if (!await c.context.internalAdapter.createAccount({
193
- userId: link.userId,
194
- ...accountKey,
195
- ...tokens,
196
- accessToken: await setTokenUtil(tokens.accessToken, c.context),
197
- refreshToken: await setTokenUtil(tokens.refreshToken, c.context),
198
- scope: tokens.scopes?.join(",")
199
- })) return redirectOnError(OAUTH_CALLBACK_ERROR_CODES.UNABLE_TO_LINK_ACCOUNT);
200
- await applyUpdateUserInfoOnLink(c, link.userId, userInfo);
201
- let toRedirectTo;
202
- try {
203
- toRedirectTo = callbackURL.toString();
204
- } catch {
205
- toRedirectTo = callbackURL;
206
- }
207
- throw c.redirect(toRedirectTo);
156
+ const linkResult = await linkOAuthAccount(c, {
157
+ link,
158
+ userInfo,
159
+ account: accountData,
160
+ profile: providerProfile,
161
+ scopes: tokens.scopes
162
+ });
163
+ if (!linkResult.linked) return redirectOnError(linkResult.error.code, linkResult.error.message);
164
+ throw c.redirect(callbackURL);
208
165
  }
209
166
  if (!userInfo.email) {
210
167
  c.context.logger.error(missingEmailLogMessage(provider.id));
211
168
  return redirectOnError(OAUTH_CALLBACK_ERROR_CODES.EMAIL_NOT_FOUND);
212
169
  }
213
- const accountData = {
214
- ...accountKey,
215
- ...tokens,
216
- scope: tokens.scopes?.join(",")
217
- };
218
170
  let result;
219
171
  try {
220
172
  result = await handleOAuthUserInfo(c, {
@@ -1,7 +1,7 @@
1
1
  import { originCheck } from "../middlewares/origin-check.mjs";
2
2
  import { getDate } from "../../utils/date.mjs";
3
3
  import { sensitiveSessionMiddleware } from "./session.mjs";
4
- import { validatePassword } from "../../utils/password.mjs";
4
+ import { assertPasswordNotTooLong, assertPasswordNotTooShort, validatePassword } from "../../utils/password.mjs";
5
5
  import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
6
6
  import { generateId } from "@better-auth/core/utils/id";
7
7
  import { createAuthEndpoint } from "@better-auth/core/api";
@@ -149,10 +149,8 @@ const resetPassword = createAuthEndpoint("/reset-password", {
149
149
  const token = ctx.body.token || ctx.query?.token;
150
150
  if (!token) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.INVALID_TOKEN);
151
151
  const { newPassword } = ctx.body;
152
- const minLength = ctx.context.password?.config.minPasswordLength;
153
- const maxLength = ctx.context.password?.config.maxPasswordLength;
154
- if (newPassword.length < minLength) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.PASSWORD_TOO_SHORT);
155
- if (newPassword.length > maxLength) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.PASSWORD_TOO_LONG);
152
+ assertPasswordNotTooShort(ctx, newPassword);
153
+ assertPasswordNotTooLong(ctx, newPassword);
156
154
  const id = `reset-password:${token}`;
157
155
  const verification = await ctx.context.internalAdapter.consumeVerificationValue(id);
158
156
  if (!verification) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.INVALID_TOKEN);
@@ -9,6 +9,7 @@ import { handleOAuthUserInfo } from "../../oauth2/link-account.mjs";
9
9
  import { generateIdTokenNonce, generateState } from "../../oauth2/state.mjs";
10
10
  import { safeCloneRequest } from "../../utils/request.mjs";
11
11
  import { createEmailVerificationToken } from "./email-verification.mjs";
12
+ import { assertPasswordNotTooLong } from "../../utils/password.mjs";
12
13
  import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
13
14
  import { additionalAuthorizationParamsSchema, supportsIdTokenSignIn, verifyProviderIdToken } from "@better-auth/core/oauth2";
14
15
  import { SocialProviderListEnum } from "@better-auth/core/social-providers";
@@ -312,6 +313,7 @@ const signInEmail = () => createAuthEndpoint("/sign-in/email", {
312
313
  }
313
314
  const { email, password } = ctx.body;
314
315
  if (!z.email().safeParse(email).success) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.INVALID_EMAIL);
316
+ assertPasswordNotTooLong(ctx, password);
315
317
  const userRecord = await ctx.context.internalAdapter.findUserByEmail(email.toLowerCase(), { includeAccounts: true });
316
318
  const credentialAccount = userRecord?.accounts.find((account) => account.providerId === "credential" && account.accountId === userRecord.user.id);
317
319
  if (!userRecord || !credentialAccount) {
@@ -4,6 +4,7 @@ import { buildSyntheticUserOutput, parseUserInput, parseUserOutput } from "../..
4
4
  import { setSessionCookie } from "../../cookies/index.mjs";
5
5
  import { safeCloneRequest } from "../../utils/request.mjs";
6
6
  import { createEmailVerificationToken } from "./email-verification.mjs";
7
+ import { assertPasswordNotTooLong, assertPasswordNotTooShort } from "../../utils/password.mjs";
7
8
  import { runWithTransaction } from "@better-auth/core/context";
8
9
  import { isDevelopment } from "@better-auth/core/env";
9
10
  import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
@@ -149,16 +150,8 @@ const signUpEmail = () => createAuthEndpoint("/sign-up/email", {
149
150
  const { name, email, password, image, callbackURL: _callbackURL, rememberMe, ...rest } = body;
150
151
  if (!z.email().safeParse(email).success) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.INVALID_EMAIL);
151
152
  if (!password || typeof password !== "string") throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.INVALID_PASSWORD);
152
- const minPasswordLength = ctx.context.password.config.minPasswordLength;
153
- if (password.length < minPasswordLength) {
154
- ctx.context.logger.warn("Password is too short");
155
- throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.PASSWORD_TOO_SHORT);
156
- }
157
- const maxPasswordLength = ctx.context.password.config.maxPasswordLength;
158
- if (password.length > maxPasswordLength) {
159
- ctx.context.logger.warn("Password is too long");
160
- throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.PASSWORD_TOO_LONG);
161
- }
153
+ assertPasswordNotTooShort(ctx, password);
154
+ assertPasswordNotTooLong(ctx, password);
162
155
  const shouldReturnGenericDuplicateResponse = ctx.context.options.emailAndPassword.requireEmailVerification || ctx.context.options.emailAndPassword.autoSignIn === false;
163
156
  const shouldSkipAutoSignIn = ctx.context.options.emailAndPassword.autoSignIn === false || shouldReturnGenericDuplicateResponse;
164
157
  const additionalUserFields = parseUserInput(ctx.context.options, rest, "create");
@@ -4,6 +4,7 @@ import { generateRandomString } from "../../crypto/random.mjs";
4
4
  import { deleteSessionCookie, setSessionCookie } from "../../cookies/index.mjs";
5
5
  import { getSessionFromCtx, isStateful, sensitiveSessionMiddleware, sessionMiddleware } from "./session.mjs";
6
6
  import { createEmailVerificationToken } from "./email-verification.mjs";
7
+ import { assertPasswordNotTooLong, assertPasswordNotTooShort } from "../../utils/password.mjs";
7
8
  import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
8
9
  import { createAuthEndpoint } from "@better-auth/core/api";
9
10
  import * as z from "zod";
@@ -158,16 +159,9 @@ const changePassword = createAuthEndpoint("/change-password", {
158
159
  }, async (ctx) => {
159
160
  const { newPassword, currentPassword, revokeOtherSessions } = ctx.body;
160
161
  const session = ctx.context.session;
161
- const minPasswordLength = ctx.context.password.config.minPasswordLength;
162
- if (newPassword.length < minPasswordLength) {
163
- ctx.context.logger.warn("Password is too short");
164
- throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.PASSWORD_TOO_SHORT);
165
- }
166
- const maxPasswordLength = ctx.context.password.config.maxPasswordLength;
167
- if (newPassword.length > maxPasswordLength) {
168
- ctx.context.logger.warn("Password is too long");
169
- throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.PASSWORD_TOO_LONG);
170
- }
162
+ assertPasswordNotTooShort(ctx, newPassword);
163
+ assertPasswordNotTooLong(ctx, newPassword);
164
+ assertPasswordNotTooLong(ctx, currentPassword);
171
165
  const account = await ctx.context.internalAdapter.findCredentialAccount(session.user.id);
172
166
  if (!account || !account.password) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.CREDENTIAL_ACCOUNT_NOT_FOUND);
173
167
  const passwordHash = await ctx.context.password.hash(newPassword);
@@ -203,16 +197,8 @@ newPassword: z.string().meta({ description: "The new password to set is required
203
197
  }, async (ctx) => {
204
198
  const { newPassword } = ctx.body;
205
199
  const session = ctx.context.session;
206
- const minPasswordLength = ctx.context.password.config.minPasswordLength;
207
- if (newPassword.length < minPasswordLength) {
208
- ctx.context.logger.warn("Password is too short");
209
- throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.PASSWORD_TOO_SHORT);
210
- }
211
- const maxPasswordLength = ctx.context.password.config.maxPasswordLength;
212
- if (newPassword.length > maxPasswordLength) {
213
- ctx.context.logger.warn("Password is too long");
214
- throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.PASSWORD_TOO_LONG);
215
- }
200
+ assertPasswordNotTooShort(ctx, newPassword);
201
+ assertPasswordNotTooLong(ctx, newPassword);
216
202
  const account = await ctx.context.internalAdapter.findCredentialAccount(session.user.id);
217
203
  const passwordHash = await ctx.context.password.hash(newPassword);
218
204
  if (!account) {
@@ -295,6 +281,7 @@ const deleteUser = createAuthEndpoint("/delete-user", {
295
281
  }
296
282
  const session = ctx.context.session;
297
283
  if (ctx.body.password) {
284
+ assertPasswordNotTooLong(ctx, ctx.body.password);
298
285
  const account = await ctx.context.internalAdapter.findCredentialAccount(session.user.id);
299
286
  if (!account || !account.password) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.CREDENTIAL_ACCOUNT_NOT_FOUND);
300
287
  if (!await ctx.context.password.verify({
@@ -0,0 +1,13 @@
1
+ import { atom } from "nanostores";
2
+ //#region src/client/query-atom.ts
3
+ const kAuthQuery = Symbol.for("better-auth:auth-query");
4
+ function isAuthQueryStore(store) {
5
+ return kAuthQuery in store && store[kAuthQuery] === true;
6
+ }
7
+ function createAuthQueryAtom(initialValue) {
8
+ const queryAtom = atom(initialValue);
9
+ Object.defineProperty(queryAtom, kAuthQuery, { value: true });
10
+ return queryAtom;
11
+ }
12
+ //#endregion
13
+ export { createAuthQueryAtom, isAuthQueryStore };
@@ -1,12 +1,13 @@
1
1
  import { isJsonEqual, withEquality } from "./equality.mjs";
2
- import { atom, onMount } from "nanostores";
2
+ import { createAuthQueryAtom } from "./query-atom.mjs";
3
+ import { onMount } from "nanostores";
3
4
  //#region src/client/query.ts
4
5
  const isServer = () => typeof window === "undefined";
5
6
  function isAuthQueryStateEqual(a, b) {
6
7
  return isJsonEqual(a.data, b.data) && a.error === b.error && a.isPending === b.isPending && a.isRefetching === b.isRefetching && a.refetch === b.refetch;
7
8
  }
8
9
  const useAuthQuery = (initializedAtom, path, $fetch, options) => {
9
- const value = atom({
10
+ const value = createAuthQueryAtom({
10
11
  data: null,
11
12
  error: null,
12
13
  isPending: true,
@@ -14,7 +15,9 @@ const useAuthQuery = (initializedAtom, path, $fetch, options) => {
14
15
  refetch: (queryParams) => fn(queryParams)
15
16
  });
16
17
  onMount(value, () => withEquality(value, isAuthQueryStateEqual));
18
+ let latestRequestId = 0;
17
19
  const fn = async (queryParams) => {
20
+ const requestId = ++latestRequestId;
18
21
  return new Promise((resolve) => {
19
22
  const opts = typeof options === "function" ? options({
20
23
  data: value.get().data,
@@ -28,15 +31,17 @@ const useAuthQuery = (initializedAtom, path, $fetch, options) => {
28
31
  ...queryParams?.query
29
32
  },
30
33
  async onSuccess(context) {
31
- const current = value.get();
32
- const stableData = current.data != null && context.data != null && isJsonEqual(current.data, context.data) ? current.data : context.data;
33
- value.set({
34
- data: stableData,
35
- error: null,
36
- isPending: false,
37
- isRefetching: false,
38
- refetch: value.value.refetch
39
- });
34
+ if (requestId === latestRequestId) {
35
+ const current = value.get();
36
+ const stableData = current.data != null && context.data != null && isJsonEqual(current.data, context.data) ? current.data : context.data;
37
+ value.set({
38
+ data: stableData,
39
+ error: null,
40
+ isPending: false,
41
+ isRefetching: false,
42
+ refetch: value.value.refetch
43
+ });
44
+ }
40
45
  await opts?.onSuccess?.(context);
41
46
  },
42
47
  async onError(context) {
@@ -44,28 +49,33 @@ const useAuthQuery = (initializedAtom, path, $fetch, options) => {
44
49
  const retryAttempts = typeof request.retry === "number" ? request.retry : request.retry?.attempts;
45
50
  const retryAttempt = request.retryAttempt || 0;
46
51
  if (retryAttempts && retryAttempt < retryAttempts) return;
47
- const isUnauthorized = context.error.status === 401;
48
- value.set({
49
- error: context.error,
50
- data: isUnauthorized ? null : value.get().data,
51
- isPending: false,
52
- isRefetching: false,
53
- refetch: value.value.refetch
54
- });
52
+ if (requestId === latestRequestId) {
53
+ const isUnauthorized = context.error.status === 401;
54
+ value.set({
55
+ error: context.error,
56
+ data: isUnauthorized ? null : value.get().data,
57
+ isPending: false,
58
+ isRefetching: false,
59
+ refetch: value.value.refetch
60
+ });
61
+ }
55
62
  await opts?.onError?.(context);
56
63
  },
57
64
  async onRequest(context) {
58
- const currentValue = value.get();
59
- value.set({
60
- isPending: currentValue.data === null,
61
- data: currentValue.data,
62
- error: null,
63
- isRefetching: true,
64
- refetch: value.value.refetch
65
- });
65
+ if (requestId === latestRequestId) {
66
+ const currentValue = value.get();
67
+ value.set({
68
+ isPending: currentValue.data === null,
69
+ data: currentValue.data,
70
+ error: null,
71
+ isRefetching: true,
72
+ refetch: value.value.refetch
73
+ });
74
+ }
66
75
  await opts?.onRequest?.(context);
67
76
  }
68
77
  }).catch((error) => {
78
+ if (requestId !== latestRequestId) return;
69
79
  value.set({
70
80
  error,
71
81
  data: value.get().data,
@@ -1,3 +1,4 @@
1
+ import { isAuthQueryStore } from "../query-atom.mjs";
1
2
  import { listenKeys } from "nanostores";
2
3
  import { useCallback, useRef, useSyncExternalStore } from "react";
3
4
  //#region src/client/react/react-store.ts
@@ -38,7 +39,7 @@ function useStore(store, options = {}) {
38
39
  return store.listen(emitChange);
39
40
  }, deps);
40
41
  const get = () => snapshotRef.current;
41
- return useSyncExternalStore(subscribe, get, get);
42
+ return useSyncExternalStore(subscribe, get, isAuthQueryStore(store) ? () => store.init : get);
42
43
  }
43
44
  //#endregion
44
45
  export { useStore };
@@ -1,4 +1,5 @@
1
1
  import { isJsonEqual, withEquality } from "./equality.mjs";
2
+ import { createAuthQueryAtom } from "./query-atom.mjs";
2
3
  import { createSessionRefreshManager } from "./session-refresh.mjs";
3
4
  import { STORE_UNMOUNT_DELAY, atom, onMount } from "nanostores";
4
5
  //#region src/client/session-atom.ts
@@ -44,7 +45,7 @@ function getSessionAtom($fetch, options) {
44
45
  freshUntil = 0;
45
46
  });
46
47
  const refetch = (queryParams) => fetchSession(queryParams);
47
- const session = atom({
48
+ const session = createAuthQueryAtom({
48
49
  data: null,
49
50
  error: null,
50
51
  isPending: true,
@@ -15,7 +15,7 @@ import { getAuthTables } from "@better-auth/core/db";
15
15
  import { createLogger, env, isProduction, isTest } from "@better-auth/core/env";
16
16
  import { BetterAuthError } from "@better-auth/core/error";
17
17
  import { generateId } from "@better-auth/core/utils/id";
18
- import { schemaCheckFor } from "@better-auth/core/db/internal";
18
+ import { runtimeSchemaCheckFor, schemaCheckFor } from "@better-auth/core/db/internal";
19
19
  import { socialProviders } from "@better-auth/core/social-providers";
20
20
  import { findInvalidTrustedProxies } from "@better-auth/core/utils/ip";
21
21
  import { createTelemetry } from "@better-auth/telemetry";
@@ -228,7 +228,8 @@ Most of the features of Better Auth will not work correctly.`);
228
228
  };
229
229
  const initOrPromise = runPluginInit(ctx);
230
230
  if (isPromise(initOrPromise)) await initOrPromise;
231
- ctx.checkSchema = schemaCheckFor(ctx.adapter);
231
+ ctx.checkSchema = runtimeSchemaCheckFor(ctx.adapter);
232
+ ctx.explicitSchemaCheck = schemaCheckFor(ctx.adapter);
232
233
  return ctx;
233
234
  }
234
235
  //#endregion
@@ -1,5 +1,5 @@
1
1
  import { GenerateStateOptions, generateIdTokenNonce, generateState, parseState } from "./state.mjs";
2
- import { applyUpdateUserInfoOnLink, handleOAuthUserInfo } from "./link-account.mjs";
2
+ import { applyUpdateUserInfoOnLink, handleOAuthUserInfo, linkOAuthAccount } from "./link-account.mjs";
3
3
  import { decryptOAuthToken, getOAuthCallbackPath, setTokenUtil } from "./utils.mjs";
4
4
  export * from "@better-auth/core/oauth2";
5
- export { GenerateStateOptions, applyUpdateUserInfoOnLink, decryptOAuthToken, generateIdTokenNonce, generateState, getOAuthCallbackPath, handleOAuthUserInfo, parseState, setTokenUtil };
5
+ export { GenerateStateOptions, applyUpdateUserInfoOnLink, decryptOAuthToken, generateIdTokenNonce, generateState, getOAuthCallbackPath, handleOAuthUserInfo, linkOAuthAccount, parseState, setTokenUtil };
@@ -1,5 +1,5 @@
1
1
  import { decryptOAuthToken, getOAuthCallbackPath, setTokenUtil } from "./utils.mjs";
2
- import { applyUpdateUserInfoOnLink, handleOAuthUserInfo } from "./link-account.mjs";
2
+ import { applyUpdateUserInfoOnLink, handleOAuthUserInfo, linkOAuthAccount } from "./link-account.mjs";
3
3
  import { generateIdTokenNonce, generateState, parseState } from "./state.mjs";
4
4
  export * from "@better-auth/core/oauth2";
5
- export { applyUpdateUserInfoOnLink, decryptOAuthToken, generateIdTokenNonce, generateState, getOAuthCallbackPath, handleOAuthUserInfo, parseState, setTokenUtil };
5
+ export { applyUpdateUserInfoOnLink, decryptOAuthToken, generateIdTokenNonce, generateState, getOAuthCallbackPath, handleOAuthUserInfo, linkOAuthAccount, parseState, setTokenUtil };
@@ -2,9 +2,49 @@ import { Account, User } from "../types/models.mjs";
2
2
  import { GenericEndpointContext, UserProvisioningSource } from "@better-auth/core";
3
3
 
4
4
  //#region src/oauth2/link-account.d.ts
5
+ type OAuthAccountData = Omit<Account, "id" | "userId" | "createdAt" | "updatedAt">;
6
+ /**
7
+ * Provider profile a freshly linked account may copy onto the local user.
8
+ * `email` and `emailVerified` are identity anchors and are stripped before
9
+ * the remaining fields are written. Provider identity is resolved separately
10
+ * from the raw profile through the provider's account-key contract.
11
+ */
12
+ type LinkedProviderProfile = {
13
+ name?: string | undefined;
14
+ email?: string | null | undefined;
15
+ emailVerified?: boolean | undefined;
16
+ image?: string | null | undefined;
17
+ };
18
+ interface LinkOAuthAccountOptions {
19
+ link: {
20
+ userId: string;
21
+ email: string;
22
+ };
23
+ userInfo: LinkedProviderProfile;
24
+ account: OAuthAccountData;
25
+ profile: Record<string, unknown>;
26
+ scopes?: string[] | undefined;
27
+ }
28
+ interface LinkOAuthAccountFailure {
29
+ linked: false;
30
+ error: {
31
+ code: string;
32
+ message?: string | undefined;
33
+ };
34
+ }
35
+ type LinkOAuthAccountResult = {
36
+ linked: true;
37
+ } | LinkOAuthAccountFailure;
38
+ declare function linkOAuthAccount(c: GenericEndpointContext, {
39
+ link,
40
+ userInfo,
41
+ account,
42
+ profile,
43
+ scopes
44
+ }: LinkOAuthAccountOptions): Promise<LinkOAuthAccountResult>;
5
45
  declare function handleOAuthUserInfo(c: GenericEndpointContext, opts: {
6
46
  userInfo: Omit<User, "createdAt" | "updatedAt">;
7
- account: Omit<Account, "id" | "userId" | "createdAt" | "updatedAt">;
47
+ account: OAuthAccountData;
8
48
  callbackURL?: string | undefined;
9
49
  disableSignUp?: boolean | undefined;
10
50
  overrideUserInfo?: boolean | undefined;
@@ -67,18 +107,6 @@ declare function handleOAuthUserInfo(c: GenericEndpointContext, opts: {
67
107
  password?: string | null | undefined;
68
108
  } | null;
69
109
  }>;
70
- /**
71
- * Provider profile a freshly linked account may copy onto the local user.
72
- * `email` and `emailVerified` are identity anchors and are stripped before
73
- * the remaining fields are written. Provider identity is resolved separately
74
- * from the raw profile through the provider's account-key contract.
75
- */
76
- type LinkedProviderProfile = {
77
- name?: string | undefined;
78
- email?: string | null | undefined;
79
- emailVerified?: boolean | undefined;
80
- image?: string | null | undefined;
81
- };
82
110
  /**
83
111
  * Apply the `account.accountLinking.updateUserInfoOnLink` policy: when enabled,
84
112
  * copy the freshly linked provider's profile onto the local user, matching the
@@ -93,4 +121,4 @@ type LinkedProviderProfile = {
93
121
  */
94
122
  declare function applyUpdateUserInfoOnLink(c: GenericEndpointContext, userId: string, userInfo: LinkedProviderProfile): Promise<User | undefined>;
95
123
  //#endregion
96
- export { applyUpdateUserInfoOnLink, handleOAuthUserInfo };
124
+ export { applyUpdateUserInfoOnLink, handleOAuthUserInfo, linkOAuthAccount };
@@ -8,7 +8,67 @@ import { createEmailVerificationToken } from "../api/routes/email-verification.m
8
8
  import { queueAfterTransactionHook, runWithTransaction } from "@better-auth/core/context";
9
9
  import { isDevelopment } from "@better-auth/core/env";
10
10
  import { APIError } from "@better-auth/core/error";
11
+ import { mergeScopes } from "@better-auth/core/oauth2";
11
12
  //#region src/oauth2/link-account.ts
13
+ function linkOAuthAccountFailure(code, message) {
14
+ return {
15
+ linked: false,
16
+ error: {
17
+ code,
18
+ message
19
+ }
20
+ };
21
+ }
22
+ async function linkOAuthAccount(c, { link, userInfo, account, profile, scopes }) {
23
+ try {
24
+ await assertValidUserInfo(c, {
25
+ user: {
26
+ ...userInfo,
27
+ id: link.userId,
28
+ email: userInfo.email ?? void 0
29
+ },
30
+ source: {
31
+ action: "link-account",
32
+ method: "oauth",
33
+ oauth: {
34
+ providerId: account.providerId,
35
+ profile
36
+ }
37
+ }
38
+ });
39
+ } catch (error) {
40
+ if (!isAPIError(error) || !error.body?.code) throw error;
41
+ return linkOAuthAccountFailure(error.body.code, error.body.message);
42
+ }
43
+ if (!c.context.trustedProviders.includes(account.providerId) && !userInfo.emailVerified || c.context.options.account?.accountLinking?.enabled === false) {
44
+ c.context.logger.error("Unable to link account - untrusted provider");
45
+ return linkOAuthAccountFailure(OAUTH_CALLBACK_ERROR_CODES.UNABLE_TO_LINK_ACCOUNT);
46
+ }
47
+ if (userInfo.email?.toLowerCase() !== link.email.toLowerCase() && c.context.options.account?.accountLinking?.allowDifferentEmails !== true) return linkOAuthAccountFailure(OAUTH_CALLBACK_ERROR_CODES.EMAIL_DOES_NOT_MATCH);
48
+ const existingAccount = await c.context.internalAdapter.findAccountByKey(account);
49
+ if (existingAccount) {
50
+ if (existingAccount.userId.toString() !== link.userId.toString()) return linkOAuthAccountFailure(OAUTH_CALLBACK_ERROR_CODES.ACCOUNT_ALREADY_LINKED_TO_DIFFERENT_USER);
51
+ const mergedScope = mergeScopes(existingAccount.scope, scopes);
52
+ const updateData = Object.fromEntries(Object.entries({
53
+ providerId: account.providerId,
54
+ accessToken: await setTokenUtil(account.accessToken, c.context),
55
+ refreshToken: await setTokenUtil(account.refreshToken, c.context),
56
+ idToken: account.idToken,
57
+ accessTokenExpiresAt: account.accessTokenExpiresAt,
58
+ refreshTokenExpiresAt: account.refreshTokenExpiresAt,
59
+ scope: mergedScope || void 0
60
+ }).filter(([_, value]) => value !== void 0));
61
+ await c.context.internalAdapter.updateAccount(existingAccount.id, updateData);
62
+ } else if (!await c.context.internalAdapter.createAccount({
63
+ userId: link.userId,
64
+ ...account,
65
+ accessToken: await setTokenUtil(account.accessToken, c.context),
66
+ refreshToken: await setTokenUtil(account.refreshToken, c.context),
67
+ scope: scopes?.join(",") ?? account.scope
68
+ })) return linkOAuthAccountFailure(OAUTH_CALLBACK_ERROR_CODES.UNABLE_TO_LINK_ACCOUNT);
69
+ await applyUpdateUserInfoOnLink(c, link.userId, userInfo);
70
+ return { linked: true };
71
+ }
12
72
  async function handleOAuthUserInfo(c, opts) {
13
73
  const { userInfo, account, callbackURL, disableSignUp, overrideUserInfo } = opts;
14
74
  const source = opts.source ?? {
@@ -326,4 +386,4 @@ async function applyUpdateUserInfoOnLink(c, userId, userInfo) {
326
386
  }
327
387
  }
328
388
  //#endregion
329
- export { applyUpdateUserInfoOnLink, handleOAuthUserInfo };
389
+ export { applyUpdateUserInfoOnLink, handleOAuthUserInfo, linkOAuthAccount };
package/dist/package.mjs CHANGED
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "1.7.5";
2
+ var version = "1.7.6";
3
3
  //#endregion
4
4
  export { version };
@@ -42,8 +42,10 @@ const admin = (options) => {
42
42
  });
43
43
  return;
44
44
  }
45
+ const bannedUserMessage = opts.bannedUserMessage;
46
+ const message = typeof bannedUserMessage === "function" ? await bannedUserMessage(user) : bannedUserMessage;
45
47
  throw APIError.from("FORBIDDEN", {
46
- message: opts.bannedUserMessage,
48
+ message,
47
49
  code: "BANNED_USER"
48
50
  });
49
51
  }
@@ -2,6 +2,7 @@ import { parseSessionOutput, parseUserOutput } from "../../db/schema.mjs";
2
2
  import { getDate } from "../../utils/date.mjs";
3
3
  import { deleteSessionCookie, expireCookie, setSessionCookie } from "../../cookies/index.mjs";
4
4
  import { getAuthoritativeSessionFromCtx, getSessionFromCtx } from "../../api/routes/session.mjs";
5
+ import { assertPasswordNotTooLong, assertPasswordNotTooShort } from "../../utils/password.mjs";
5
6
  import { ADMIN_ERROR_CODES } from "./error-codes.mjs";
6
7
  import { hasPermission } from "./has-permission.mjs";
7
8
  import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
@@ -190,6 +191,7 @@ const createUser = (opts) => createAuthEndpoint("/admin/create-user", {
190
191
  }
191
192
  const email = ctx.body.email.toLowerCase();
192
193
  if (!z.email().safeParse(email).success) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.INVALID_EMAIL);
194
+ if (ctx.body.password) assertPasswordNotTooLong(ctx, ctx.body.password);
193
195
  if (await ctx.context.internalAdapter.findUserByEmail(email)) throw APIError.from("BAD_REQUEST", ADMIN_ERROR_CODES.USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL);
194
196
  const user = await ctx.context.internalAdapter.createUser({
195
197
  ...userData,
@@ -823,16 +825,8 @@ const setUserPassword = (opts) => createAuthEndpoint("/admin/set-user-password",
823
825
  permissions: { user: ["set-password"] }
824
826
  })) throw APIError.from("FORBIDDEN", ADMIN_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_SET_USERS_PASSWORD);
825
827
  const { newPassword, userId } = ctx.body;
826
- const minPasswordLength = ctx.context.password.config.minPasswordLength;
827
- if (newPassword.length < minPasswordLength) {
828
- ctx.context.logger.warn("Password is too short");
829
- throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.PASSWORD_TOO_SHORT);
830
- }
831
- const maxPasswordLength = ctx.context.password.config.maxPasswordLength;
832
- if (newPassword.length > maxPasswordLength) {
833
- ctx.context.logger.warn("Password is too long");
834
- throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.PASSWORD_TOO_LONG);
835
- }
828
+ assertPasswordNotTooShort(ctx, newPassword);
829
+ assertPasswordNotTooLong(ctx, newPassword);
836
830
  const user = await ctx.context.internalAdapter.findUserById(userId);
837
831
  if (!user) throw APIError.from("NOT_FOUND", BASE_ERROR_CODES.USER_NOT_FOUND);
838
832
  const hashedPassword = await ctx.context.password.hash(newPassword);
@@ -2,6 +2,7 @@ import { Session, User } from "../../types/models.mjs";
2
2
  import { InferOptionSchema } from "../../types/plugins.mjs";
3
3
  import { AccessControl, Role } from "../access/types.mjs";
4
4
  import { AdminSchema } from "./schema.mjs";
5
+ import { Awaitable } from "@better-auth/core";
5
6
 
6
7
  //#region src/plugins/admin/types.d.ts
7
8
  interface UserWithRole extends User {
@@ -70,8 +71,10 @@ interface AdminOptions {
70
71
  * Message to show when a user is banned
71
72
  *
72
73
  * By default, the message is "You have been banned from this application"
74
+ *
75
+ * Can also be a function that receives the banned user and returns a message.
73
76
  */
74
- bannedUserMessage?: string | undefined;
77
+ bannedUserMessage?: string | ((user: UserWithRole & Record<string, unknown>) => Awaitable<string>) | undefined;
75
78
  /**
76
79
  * Whether to allow impersonating other admins.
77
80
  *
@@ -4,6 +4,7 @@ declare const Providers: {
4
4
  readonly GOOGLE_RECAPTCHA: "google-recaptcha";
5
5
  readonly HCAPTCHA: "hcaptcha";
6
6
  readonly CAPTCHAFOX: "captchafox";
7
+ readonly VERCEL_BOTID: "vercel-botid";
7
8
  };
8
9
  //#endregion
9
10
  export { Providers };
@@ -15,7 +15,8 @@ const Providers = {
15
15
  CLOUDFLARE_TURNSTILE: "cloudflare-turnstile",
16
16
  GOOGLE_RECAPTCHA: "google-recaptcha",
17
17
  HCAPTCHA: "hcaptcha",
18
- CAPTCHAFOX: "captchafox"
18
+ CAPTCHAFOX: "captchafox",
19
+ VERCEL_BOTID: "vercel-botid"
19
20
  };
20
21
  const siteVerifyMap = {
21
22
  [Providers.CLOUDFLARE_TURNSTILE]: "https://challenges.cloudflare.com/turnstile/v0/siteverify",
@@ -1,4 +1,4 @@
1
- import { BaseCaptchaOptions, CaptchaFoxOptions, CaptchaOptions, CloudflareTurnstileOptions, GoogleRecaptchaOptions, HCaptchaOptions, Provider } from "./types.mjs";
1
+ import { BaseCaptchaOptions, BotIdVerification, CaptchaFoxOptions, CaptchaOptions, CloudflareTurnstileOptions, GoogleRecaptchaOptions, HCaptchaOptions, Provider, ValidateRequestContext, VercelBotIdOptions } from "./types.mjs";
2
2
  import * as _$_better_auth_core0 from "@better-auth/core";
3
3
  import * as _$_better_auth_core_utils_error_codes0 from "@better-auth/core/utils/error-codes";
4
4
 
@@ -24,4 +24,4 @@ declare const captcha: (options: CaptchaOptions) => {
24
24
  options: CaptchaOptions;
25
25
  };
26
26
  //#endregion
27
- export { BaseCaptchaOptions, CaptchaFoxOptions, CaptchaOptions, CloudflareTurnstileOptions, GoogleRecaptchaOptions, HCaptchaOptions, Provider, captcha };
27
+ export { BaseCaptchaOptions, BotIdVerification, CaptchaFoxOptions, CaptchaOptions, CloudflareTurnstileOptions, GoogleRecaptchaOptions, HCaptchaOptions, Provider, ValidateRequestContext, VercelBotIdOptions, captcha };
@@ -7,6 +7,7 @@ import { captchaFox } from "./verify-handlers/captchafox.mjs";
7
7
  import { cloudflareTurnstile } from "./verify-handlers/cloudflare-turnstile.mjs";
8
8
  import { googleRecaptcha } from "./verify-handlers/google-recaptcha.mjs";
9
9
  import { hCaptcha } from "./verify-handlers/h-captcha.mjs";
10
+ import { vercelBotId } from "./verify-handlers/vercel-botid.mjs";
10
11
  import { getIP } from "@better-auth/core/utils/ip";
11
12
  //#region src/plugins/captcha/index.ts
12
13
  const normalizeEndpointPath = (pathname, basePath) => {
@@ -26,6 +27,11 @@ const captcha = (options) => ({
26
27
  const basePath = ctx.options.basePath ?? "/api/auth";
27
28
  const pathname = normalizeEndpointPath(url.pathname, basePath);
28
29
  if (!endpoints.some((endpoint) => endpoint.includes("*") ? wildcardMatch(endpoint)(pathname) : endpoint === pathname)) return;
30
+ if (options.provider === Providers.VERCEL_BOTID) return await vercelBotId({
31
+ request,
32
+ checkBotId: options.checkBotId,
33
+ validateRequest: options.validateRequest
34
+ });
29
35
  if (!options.secretKey) throw new Error(INTERNAL_ERROR_CODES.MISSING_SECRET_KEY.message);
30
36
  const captchaResponse = request.headers.get("x-captcha-response");
31
37
  const remoteUserIP = getIP(request, ctx.options) ?? void 0;
@@ -47,6 +47,48 @@ interface CaptchaFoxOptions extends BaseCaptchaOptions {
47
47
  provider: typeof Providers.CAPTCHAFOX;
48
48
  siteKey?: string | undefined;
49
49
  }
50
- type CaptchaOptions = GoogleRecaptchaOptions | CloudflareTurnstileOptions | HCaptchaOptions | CaptchaFoxOptions;
50
+ /**
51
+ * BotID verdict fields available to custom request validation.
52
+ *
53
+ * @see https://vercel.com/docs/botid/verified-bots
54
+ */
55
+ type BotIdVerification = {
56
+ isBot: boolean;
57
+ isVerifiedBot?: boolean | undefined;
58
+ verifiedBotName?: string | undefined;
59
+ verifiedBotCategory?: string | undefined;
60
+ };
61
+ type ValidateRequestContext = {
62
+ request: Request;
63
+ verification: BotIdVerification;
64
+ };
65
+ /**
66
+ * Protect auth requests with Vercel BotID. The browser must also protect the
67
+ * same request paths and methods with BotID's client SDK.
68
+ *
69
+ * @see https://vercel.com/docs/botid/get-started
70
+ */
71
+ interface VercelBotIdOptions {
72
+ provider: typeof Providers.VERCEL_BOTID;
73
+ /**
74
+ * Auth paths to verify, without the Better Auth base path.
75
+ *
76
+ * @default ["/sign-up/email", "/sign-in/email", "/request-password-reset"]
77
+ */
78
+ endpoints?: string[] | undefined;
79
+ /**
80
+ * Vercel's server-side check for the current request. Pass `checkBotId` from
81
+ * `botid/server`, or wrap it to supply SDK options.
82
+ */
83
+ checkBotId: () => Promise<BotIdVerification>;
84
+ /**
85
+ * Override the default `isBot === false` decision. Return `true` to allow
86
+ * the request, including a verified bot you trust.
87
+ *
88
+ * @see https://vercel.com/docs/botid/verified-bots
89
+ */
90
+ validateRequest?: (ctx: ValidateRequestContext) => boolean | Promise<boolean>;
91
+ }
92
+ type CaptchaOptions = GoogleRecaptchaOptions | CloudflareTurnstileOptions | HCaptchaOptions | CaptchaFoxOptions | VercelBotIdOptions;
51
93
  //#endregion
52
- export { BaseCaptchaOptions, CaptchaFoxOptions, CaptchaOptions, CloudflareTurnstileOptions, GoogleRecaptchaOptions, HCaptchaOptions, Provider };
94
+ export { BaseCaptchaOptions, BotIdVerification, CaptchaFoxOptions, CaptchaOptions, CloudflareTurnstileOptions, GoogleRecaptchaOptions, HCaptchaOptions, Provider, ValidateRequestContext, VercelBotIdOptions };
@@ -5,5 +5,15 @@ const encodeToURLParams = (obj) => {
5
5
  for (const [key, value] of Object.entries(obj)) if (value !== void 0 && value !== null) params.append(key, String(value));
6
6
  return params.toString();
7
7
  };
8
+ /**
9
+ * Rejects after `timeoutMs` without cancelling the underlying promise.
10
+ */
11
+ const withTimeout = (promise, timeoutMs) => {
12
+ let timer;
13
+ const timeout = new Promise((_resolve, reject) => {
14
+ timer = setTimeout(() => reject(/* @__PURE__ */ new Error("Operation timed out")), timeoutMs);
15
+ });
16
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
17
+ };
8
18
  //#endregion
9
- export { encodeToURLParams };
19
+ export { encodeToURLParams, withTimeout };
@@ -0,0 +1,27 @@
1
+ import { middlewareResponse } from "../../../utils/middleware-response.mjs";
2
+ import { CAPTCHA_VERIFY_TIMEOUT_MS } from "../constants.mjs";
3
+ import { EXTERNAL_ERROR_CODES, INTERNAL_ERROR_CODES } from "../error-codes.mjs";
4
+ import { withTimeout } from "../utils.mjs";
5
+ //#region src/plugins/captcha/verify-handlers/vercel-botid.ts
6
+ const vercelBotId = async ({ request, checkBotId, validateRequest }) => {
7
+ let isValid;
8
+ try {
9
+ isValid = await withTimeout((async () => {
10
+ const verification = await checkBotId();
11
+ return validateRequest ? validateRequest({
12
+ request,
13
+ verification
14
+ }) : verification.isBot === false;
15
+ })(), CAPTCHA_VERIFY_TIMEOUT_MS);
16
+ } catch (error) {
17
+ throw new Error(INTERNAL_ERROR_CODES.SERVICE_UNAVAILABLE.message, { cause: error });
18
+ }
19
+ if (isValid) return void 0;
20
+ return middlewareResponse({
21
+ message: EXTERNAL_ERROR_CODES.VERIFICATION_FAILED.message,
22
+ code: EXTERNAL_ERROR_CODES.VERIFICATION_FAILED.code,
23
+ status: 403
24
+ });
25
+ };
26
+ //#endregion
27
+ export { vercelBotId };
@@ -6,6 +6,7 @@ import { symmetricDecrypt } from "../../crypto/index.mjs";
6
6
  import { setCookieCache, setSessionCookie } from "../../cookies/index.mjs";
7
7
  import { revokeUnprovenAccountAccess } from "../../db/revoke-unproven-account-access.mjs";
8
8
  import { getSessionFromCtx, sensitiveSessionMiddleware } from "../../api/routes/session.mjs";
9
+ import { assertPasswordNotTooLong, assertPasswordNotTooShort } from "../../utils/password.mjs";
9
10
  import { APIError as APIError$1 } from "../../api/index.mjs";
10
11
  import { EMAIL_OTP_ERROR_CODES } from "./error-codes.mjs";
11
12
  import { splitAtLastColon, toOTPIdentifier } from "./utils.mjs";
@@ -581,10 +582,8 @@ const resetPasswordEmailOTP = (opts) => createAuthEndpoint("/email-otp/reset-pas
581
582
  } }
582
583
  }, async (ctx) => {
583
584
  const email = ctx.body.email.toLowerCase();
584
- const minPasswordLength = ctx.context.password.config.minPasswordLength;
585
- if (ctx.body.password.length < minPasswordLength) throw APIError$1.from("BAD_REQUEST", BASE_ERROR_CODES.PASSWORD_TOO_SHORT);
586
- const maxPasswordLength = ctx.context.password.config.maxPasswordLength;
587
- if (ctx.body.password.length > maxPasswordLength) throw APIError$1.from("BAD_REQUEST", BASE_ERROR_CODES.PASSWORD_TOO_LONG);
585
+ assertPasswordNotTooShort(ctx, ctx.body.password);
586
+ assertPasswordNotTooLong(ctx, ctx.body.password);
588
587
  await atomicVerifyOTP(ctx, opts, toOTPIdentifier("forget-password", email), ctx.body.otp);
589
588
  const user = await ctx.context.internalAdapter.findUserByEmail(email);
590
589
  if (!user) throw APIError$1.from("BAD_REQUEST", BASE_ERROR_CODES.USER_NOT_FOUND);
@@ -10,7 +10,7 @@ import { admin } from "./admin/admin.mjs";
10
10
  import { AnonymousOptions, AnonymousSession, UserWithAnonymous } from "./anonymous/types.mjs";
11
11
  import { anonymous } from "./anonymous/index.mjs";
12
12
  import { BearerOptions, bearer } from "./bearer/index.mjs";
13
- import { BaseCaptchaOptions, CaptchaFoxOptions, CaptchaOptions, CloudflareTurnstileOptions, GoogleRecaptchaOptions, HCaptchaOptions, Provider } from "./captcha/types.mjs";
13
+ import { BaseCaptchaOptions, BotIdVerification, CaptchaFoxOptions, CaptchaOptions, CloudflareTurnstileOptions, GoogleRecaptchaOptions, HCaptchaOptions, Provider, ValidateRequestContext, VercelBotIdOptions } from "./captcha/types.mjs";
14
14
  import { captcha } from "./captcha/index.mjs";
15
15
  import { CustomSessionPluginOptions, customSession } from "./custom-session/index.mjs";
16
16
  import { TimeString, ms, sec } from "../utils/time.mjs";
@@ -66,4 +66,4 @@ import { USERNAME_ERROR_CODES } from "./username/error-codes.mjs";
66
66
  import { UsernameOptions, UsernamePlugin, UsernamePluginWithoutDisplayUsername, username } from "./username/index.mjs";
67
67
  import { hasPermission } from "./organization/has-permission.mjs";
68
68
  import { DefaultOrganizationPlugin, DynamicAccessControlEndpoints, OrganizationCreator, OrganizationEndpoints, OrganizationPlugin, TeamEndpoints, organization, parseRoles } from "./organization/organization.mjs";
69
- export { AccessControl, AdminOptions, AnonymousOptions, AnonymousSession, ArrayElement, Auth0Options, AuthorizeResponse, BackupCodeOptions, BaseCaptchaOptions, BaseOAuthProviderOptions, BearerOptions, CaptchaFoxOptions, CaptchaOptions, CloudflareTurnstileOptions, CustomSessionPluginOptions, DefaultOrganizationPlugin, DeviceAuthorizationGrant, DeviceAuthorizationGrantAuthorization, DeviceAuthorizationOptions, DeviceAuthorizationPluginOptions, DeviceAuthorizationRequest, DeviceCode, DeviceCodeRedemptionAuthorization, DeviceCodeRedemptionResult, DynamicAccessControlEndpoints, MULTI_SESSION_ERROR_CODES as ERROR_CODES, EmailOTPOptions, ExactRoleStatements, FieldSchema, GenericOAuthConfig, GenericOAuthOptions, GenericOAuthUserInfo, GoogleRecaptchaOptions, GumroadOptions, HCaptchaOptions, HIDE_METADATA, HaveIBeenPwnedOptions, HubSpotOptions, InferAdminRolesFromOption, InferInvitation, InferMember, InferOptionSchema, InferOrganization, InferOrganizationRolesFromOption, InferOrganizationZodRolesFromOption, InferPluginContext, InferPluginErrorCodes, InferPluginIDs, InferTeam, Invitation, InvitationInput, InvitationStatus, JWKOptions, JWSAlgorithms, Jwk, JwtOptions, KeycloakOptions, LastLoginMethodOptions, LineOptions, LoginResult, MagicLinkOptions, Member, MemberInput, MicrosoftEntraIdOptions, MultiSessionConfig, OAUTH_POPUP_COMPLETE_SCRIPT, OAUTH_POPUP_DATA_ELEMENT_ID, OAUTH_POPUP_ERROR_CODES, OAUTH_POPUP_MESSAGE_TYPE, OAUTH_POPUP_SCRIPT_CSP_HASH, OAuthPopupData, OAuthPopupMessage, OAuthProxyOptions, OTPOptions, OktaOptions, OneTapOptions, OneTimeTokenOptions, OpenAPIModelSchema, OpenAPIOptions, OpenAPIParameter, OpenAPISchema, Organization, OrganizationCreator, OrganizationEndpoints, OrganizationInput, OrganizationOptions, OrganizationPlugin, OrganizationRole, OrganizationSchema, POPUP_MARKER_COOKIE, Path, PatreonOptions, PhoneNumberOptions, Provider, ResolvedSigningKey, Role, RoleAuthorizeRequest, RoleInput, RoleStatements, SIWEPluginOptions, SessionWithImpersonatedBy, SlackOptions, Statements, SubArray, Subset, TOTPOptions, TWO_FACTOR_ERROR_CODES, Team, TeamEndpoints, TeamInput, TeamMember, TeamMemberInput, TestAuthOptions, TestCookie, TestHelpers, TestUtilsOptions, TimeString, TwoFactorOptions, TwoFactorProvider, TwoFactorTable, USERNAME_ERROR_CODES, UserWithAnonymous, UserWithPhoneNumber, UserWithRole, UserWithTwoFactor, UsernameOptions, UsernamePlugin, UsernamePluginWithoutDisplayUsername, YandexOptions, admin, anonymous, auth0, backupCode2fa, bearer, captcha, createAccessControl, createJwk, customSession, defaultRolesSchema, deviceAuthorization, deviceAuthorizationOptionsSchema, emailOTP, encodeBackupCodes, generateBackupCodes, generateExportedKeyPair, generator, genericOAuth, getBackupCodes, getJwtToken, getOrgAdapter, gumroad, hasPermission, haveIBeenPwned, hubspot, invitationSchema, invitationStatus, isPasswordCompromised, jwt, keycloak, lastLoginMethod, line, magicLink, memberSchema, microsoftEntraId, ms, multiSession, oAuthProxy, oauthPopup, okta, oneTap, oneTimeToken, openAPI, organization, organizationRoleSchema, organizationSchema, otp2fa, parseRoles, patreon, phoneNumber, redeemDeviceCode, resolveSigningKey, role, roleSchema, sec, signJWT, siwe, slack, teamMemberSchema, teamSchema, testUtils, toExpJWT, totp2fa, twoFactor, twoFactorClient, username, verifyBackupCode, verifyJWT, yandex };
69
+ export { AccessControl, AdminOptions, AnonymousOptions, AnonymousSession, ArrayElement, Auth0Options, AuthorizeResponse, BackupCodeOptions, BaseCaptchaOptions, BaseOAuthProviderOptions, BearerOptions, BotIdVerification, CaptchaFoxOptions, CaptchaOptions, CloudflareTurnstileOptions, CustomSessionPluginOptions, DefaultOrganizationPlugin, DeviceAuthorizationGrant, DeviceAuthorizationGrantAuthorization, DeviceAuthorizationOptions, DeviceAuthorizationPluginOptions, DeviceAuthorizationRequest, DeviceCode, DeviceCodeRedemptionAuthorization, DeviceCodeRedemptionResult, DynamicAccessControlEndpoints, MULTI_SESSION_ERROR_CODES as ERROR_CODES, EmailOTPOptions, ExactRoleStatements, FieldSchema, GenericOAuthConfig, GenericOAuthOptions, GenericOAuthUserInfo, GoogleRecaptchaOptions, GumroadOptions, HCaptchaOptions, HIDE_METADATA, HaveIBeenPwnedOptions, HubSpotOptions, InferAdminRolesFromOption, InferInvitation, InferMember, InferOptionSchema, InferOrganization, InferOrganizationRolesFromOption, InferOrganizationZodRolesFromOption, InferPluginContext, InferPluginErrorCodes, InferPluginIDs, InferTeam, Invitation, InvitationInput, InvitationStatus, JWKOptions, JWSAlgorithms, Jwk, JwtOptions, KeycloakOptions, LastLoginMethodOptions, LineOptions, LoginResult, MagicLinkOptions, Member, MemberInput, MicrosoftEntraIdOptions, MultiSessionConfig, OAUTH_POPUP_COMPLETE_SCRIPT, OAUTH_POPUP_DATA_ELEMENT_ID, OAUTH_POPUP_ERROR_CODES, OAUTH_POPUP_MESSAGE_TYPE, OAUTH_POPUP_SCRIPT_CSP_HASH, OAuthPopupData, OAuthPopupMessage, OAuthProxyOptions, OTPOptions, OktaOptions, OneTapOptions, OneTimeTokenOptions, OpenAPIModelSchema, OpenAPIOptions, OpenAPIParameter, OpenAPISchema, Organization, OrganizationCreator, OrganizationEndpoints, OrganizationInput, OrganizationOptions, OrganizationPlugin, OrganizationRole, OrganizationSchema, POPUP_MARKER_COOKIE, Path, PatreonOptions, PhoneNumberOptions, Provider, ResolvedSigningKey, Role, RoleAuthorizeRequest, RoleInput, RoleStatements, SIWEPluginOptions, SessionWithImpersonatedBy, SlackOptions, Statements, SubArray, Subset, TOTPOptions, TWO_FACTOR_ERROR_CODES, Team, TeamEndpoints, TeamInput, TeamMember, TeamMemberInput, TestAuthOptions, TestCookie, TestHelpers, TestUtilsOptions, TimeString, TwoFactorOptions, TwoFactorProvider, TwoFactorTable, USERNAME_ERROR_CODES, UserWithAnonymous, UserWithPhoneNumber, UserWithRole, UserWithTwoFactor, UsernameOptions, UsernamePlugin, UsernamePluginWithoutDisplayUsername, ValidateRequestContext, VercelBotIdOptions, YandexOptions, admin, anonymous, auth0, backupCode2fa, bearer, captcha, createAccessControl, createJwk, customSession, defaultRolesSchema, deviceAuthorization, deviceAuthorizationOptionsSchema, emailOTP, encodeBackupCodes, generateBackupCodes, generateExportedKeyPair, generator, genericOAuth, getBackupCodes, getJwtToken, getOrgAdapter, gumroad, hasPermission, haveIBeenPwned, hubspot, invitationSchema, invitationStatus, isPasswordCompromised, jwt, keycloak, lastLoginMethod, line, magicLink, memberSchema, microsoftEntraId, ms, multiSession, oAuthProxy, oauthPopup, okta, oneTap, oneTimeToken, openAPI, organization, organizationRoleSchema, organizationSchema, otp2fa, parseRoles, patreon, phoneNumber, redeemDeviceCode, resolveSigningKey, role, roleSchema, sec, signJWT, siwe, slack, teamMemberSchema, teamSchema, testUtils, toExpJWT, totp2fa, twoFactor, twoFactorClient, username, verifyBackupCode, verifyJWT, yandex };
@@ -7,7 +7,7 @@ import { setSessionCookie } from "../../cookies/index.mjs";
7
7
  import { resolveOAuthAccountKey, toOAuthProfileRecord } from "../../oauth2/account-key.mjs";
8
8
  import { redirectOnError } from "../../oauth2/errors.mjs";
9
9
  import { getOAuthCallbackPath } from "../../oauth2/utils.mjs";
10
- import { handleOAuthUserInfo } from "../../oauth2/link-account.mjs";
10
+ import { handleOAuthUserInfo, linkOAuthAccount } from "../../oauth2/link-account.mjs";
11
11
  import { setOAuthState } from "../../api/state/oauth.mjs";
12
12
  import { parseGenericState } from "../../state.mjs";
13
13
  import { PACKAGE_VERSION } from "../../version.mjs";
@@ -37,6 +37,7 @@ const passthroughPayloadSchema = z.looseObject({
37
37
  updatedAt: true
38
38
  }).shape),
39
39
  profile: z.record(z.string(), z.unknown()).optional(),
40
+ scopes: z.array(z.string()).optional(),
40
41
  state: z.string().min(1),
41
42
  callbackURL: z.string().min(1),
42
43
  newUserURL: z.string().optional(),
@@ -107,7 +108,19 @@ const oAuthProxy = (opts) => {
107
108
  ctx.context.logger.error(`OAuth proxy payload expired or invalid (age: ${age}s, maxAge: ${maxAge}s)`);
108
109
  throw redirectOnError(ctx, errorURL, "payload_expired");
109
110
  }
110
- if (!await restoreOAuthProxyState(ctx, payload.state)) throw redirectOnError(ctx, errorURL, "state_mismatch");
111
+ const stateData = await restoreOAuthProxyState(ctx, payload.state);
112
+ if (!stateData) throw redirectOnError(ctx, errorURL, "state_mismatch");
113
+ if (stateData.link) {
114
+ const linkResult = await linkOAuthAccount(ctx, {
115
+ link: stateData.link,
116
+ userInfo: payload.userInfo,
117
+ account: payload.account,
118
+ profile: payload.profile ?? {},
119
+ scopes: payload.scopes ?? payload.account.scope?.split(",")
120
+ });
121
+ if (!linkResult.linked) throw redirectOnError(ctx, errorURL, linkResult.error.code, linkResult.error.message);
122
+ throw ctx.redirect(payload.callbackURL);
123
+ }
111
124
  let result;
112
125
  try {
113
126
  result = await handleOAuthUserInfo(ctx, {
@@ -185,7 +198,7 @@ const oAuthProxy = (opts) => {
185
198
  hooks: {
186
199
  before: [{
187
200
  matcher(context) {
188
- return !!context.path?.startsWith("/sign-in/social");
201
+ return !!context.path?.startsWith("/sign-in/social") || context.path === "/link-social";
189
202
  },
190
203
  handler: createAuthMiddleware(async (ctx) => {
191
204
  if (checkSkipProxy(ctx, opts)) return;
@@ -307,6 +320,7 @@ const oAuthProxy = (opts) => {
307
320
  emailVerified: userInfo.emailVerified
308
321
  },
309
322
  profile: providerProfile,
323
+ scopes: tokens.scopes,
310
324
  account: {
311
325
  ...accountKey,
312
326
  accessToken: tokens.accessToken,
@@ -333,14 +347,14 @@ const oAuthProxy = (opts) => {
333
347
  }],
334
348
  after: [{
335
349
  matcher(context) {
336
- return !!context.path?.startsWith("/sign-in/social");
350
+ return !!context.path?.startsWith("/sign-in/social") || context.path === "/link-social";
337
351
  },
338
352
  handler: createAuthMiddleware(async (ctx) => {
339
353
  if (checkSkipProxy(ctx, opts)) return;
340
354
  const signInResponse = ctx.context.returned;
341
355
  if (!signInResponse || typeof signInResponse !== "object" || !("url" in signInResponse)) return;
342
356
  const { url: providerURL } = signInResponse;
343
- if (typeof providerURL !== "string") return;
357
+ if (typeof providerURL !== "string" || providerURL.length === 0) return;
344
358
  const oauthURL = new URL(providerURL);
345
359
  const originalState = oauthURL.searchParams.get("state");
346
360
  if (!originalState) return;
@@ -4,6 +4,7 @@ import { generateRandomString } from "../../crypto/random.mjs";
4
4
  import { setSessionCookie } from "../../cookies/index.mjs";
5
5
  import { getSessionFromCtx } from "../../api/routes/session.mjs";
6
6
  import { HIDE_METADATA } from "../../utils/hide-metadata.mjs";
7
+ import { assertPasswordNotTooLong, assertPasswordNotTooShort } from "../../utils/password.mjs";
7
8
  import { PHONE_NUMBER_ERROR_CODES } from "./error-codes.mjs";
8
9
  import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
9
10
  import { createAuthEndpoint } from "@better-auth/core/api";
@@ -54,6 +55,7 @@ const signInPhoneNumber = (opts) => createAuthEndpoint("/sign-in/phone-number",
54
55
  if (opts.phoneNumberValidator) {
55
56
  if (!await opts.phoneNumberValidator(ctx.body.phoneNumber)) throw APIError.from("BAD_REQUEST", PHONE_NUMBER_ERROR_CODES.INVALID_PHONE_NUMBER);
56
57
  }
58
+ assertPasswordNotTooLong(ctx, password);
57
59
  const user = await ctx.context.adapter.findOne({
58
60
  model: "user",
59
61
  where: [{
@@ -466,10 +468,8 @@ const resetPasswordPhoneNumber = (opts) => createAuthEndpoint("/phone-number/res
466
468
  }]
467
469
  });
468
470
  if (!user) throw APIError.from("BAD_REQUEST", PHONE_NUMBER_ERROR_CODES.UNEXPECTED_ERROR);
469
- const minLength = ctx.context.password.config.minPasswordLength;
470
- const maxLength = ctx.context.password.config.maxPasswordLength;
471
- if (ctx.body.newPassword.length < minLength) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.PASSWORD_TOO_SHORT);
472
- if (ctx.body.newPassword.length > maxLength) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.PASSWORD_TOO_LONG);
471
+ assertPasswordNotTooShort(ctx, ctx.body.newPassword);
472
+ assertPasswordNotTooLong(ctx, ctx.body.newPassword);
473
473
  const hashedPassword = await ctx.context.password.hash(ctx.body.newPassword);
474
474
  if (!await ctx.context.internalAdapter.findCredentialAccount(user.id)) await ctx.context.internalAdapter.createAccount({
475
475
  userId: user.id,
@@ -2,6 +2,7 @@ import { mergeSchema, parseUserOutput } from "../../db/schema.mjs";
2
2
  import { setSessionCookie } from "../../cookies/index.mjs";
3
3
  import { getSessionFromCtx } from "../../api/routes/session.mjs";
4
4
  import { createEmailVerificationToken } from "../../api/routes/email-verification.mjs";
5
+ import { assertPasswordNotTooLong } from "../../utils/password.mjs";
5
6
  import { PACKAGE_VERSION } from "../../version.mjs";
6
7
  import { USERNAME_ERROR_CODES } from "./error-codes.mjs";
7
8
  import { getSchema } from "./schema.mjs";
@@ -166,6 +167,7 @@ const usernameImpl = (options, includeDisplayUsername) => {
166
167
  throw APIError.from("UNPROCESSABLE_ENTITY", USERNAME_ERROR_CODES.USERNAME_TOO_LONG);
167
168
  }
168
169
  if (!await (options?.usernameValidator || defaultUsernameValidator)(username)) throw APIError.from("UNPROCESSABLE_ENTITY", USERNAME_ERROR_CODES.INVALID_USERNAME);
170
+ assertPasswordNotTooLong(ctx, ctx.body.password);
169
171
  const user = await ctx.context.adapter.findOne({
170
172
  model: "user",
171
173
  where: [{
@@ -1,6 +1,19 @@
1
1
  import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
2
2
  //#region src/utils/password.ts
3
+ function assertPasswordNotTooShort(ctx, password) {
4
+ if (password.length < ctx.context.password.config.minPasswordLength) {
5
+ ctx.context.logger.warn("Password is too short");
6
+ throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.PASSWORD_TOO_SHORT);
7
+ }
8
+ }
9
+ function assertPasswordNotTooLong(ctx, password) {
10
+ if (password.length > ctx.context.password.config.maxPasswordLength) {
11
+ ctx.context.logger.warn("Password is too long");
12
+ throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.PASSWORD_TOO_LONG);
13
+ }
14
+ }
3
15
  async function validatePassword(ctx, data) {
16
+ assertPasswordNotTooLong(ctx, data.password);
4
17
  const credentialAccount = await ctx.context.internalAdapter.findCredentialAccount(data.userId);
5
18
  const currentPassword = credentialAccount?.password;
6
19
  if (!credentialAccount || !currentPassword) return false;
@@ -10,9 +23,10 @@ async function validatePassword(ctx, data) {
10
23
  });
11
24
  }
12
25
  async function checkPassword(userId, c) {
26
+ const password = c.body.password;
27
+ if (typeof password === "string") assertPasswordNotTooLong(c, password);
13
28
  const credentialAccount = await c.context.internalAdapter.findCredentialAccount(userId);
14
29
  const currentPassword = credentialAccount?.password;
15
- const password = c.body.password;
16
30
  if (!credentialAccount || !currentPassword || !password) {
17
31
  if (password) await c.context.password.hash(password);
18
32
  throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.INVALID_PASSWORD);
@@ -29,4 +43,4 @@ async function shouldRequirePassword(ctx, userId, allowPasswordless) {
29
43
  return Boolean(credentialAccount?.password);
30
44
  }
31
45
  //#endregion
32
- export { checkPassword, shouldRequirePassword, validatePassword };
46
+ export { assertPasswordNotTooLong, assertPasswordNotTooShort, checkPassword, shouldRequirePassword, validatePassword };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "better-auth",
3
- "version": "1.7.5",
3
+ "version": "1.7.6",
4
4
  "description": "The most comprehensive authentication framework for TypeScript.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -473,13 +473,13 @@
473
473
  "kysely": "^0.28.17 || ^0.29.0",
474
474
  "nanostores": "^1.3.0",
475
475
  "zod": "^4.5.4",
476
- "@better-auth/core": "1.7.5",
477
- "@better-auth/drizzle-adapter": "1.7.5",
478
- "@better-auth/kysely-adapter": "1.7.5",
479
- "@better-auth/memory-adapter": "1.7.5",
480
- "@better-auth/mongo-adapter": "1.7.5",
481
- "@better-auth/prisma-adapter": "1.7.5",
482
- "@better-auth/telemetry": "1.7.5"
476
+ "@better-auth/core": "1.7.6",
477
+ "@better-auth/drizzle-adapter": "1.7.6",
478
+ "@better-auth/kysely-adapter": "1.7.6",
479
+ "@better-auth/memory-adapter": "1.7.6",
480
+ "@better-auth/mongo-adapter": "1.7.6",
481
+ "@better-auth/prisma-adapter": "1.7.6",
482
+ "@better-auth/telemetry": "1.7.6"
483
483
  },
484
484
  "devDependencies": {
485
485
  "@lynx-js/react": "^0.121.2",
@@ -494,6 +494,7 @@
494
494
  "@types/pg": "^8.20.0",
495
495
  "@types/react": "^19.2.17",
496
496
  "@types/react-dom": "^19.2.3",
497
+ "botid": "^1.5.10",
497
498
  "happy-dom": "^20.8.9",
498
499
  "msw": "^2.14.6",
499
500
  "next": "^16.3.4",