better-auth 1.7.5 → 1.7.7

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 (44) hide show
  1. package/dist/api/rate-limiter/index.mjs +4 -1
  2. package/dist/api/routes/callback.mjs +16 -64
  3. package/dist/api/routes/password.mjs +3 -5
  4. package/dist/api/routes/sign-in.mjs +4 -1
  5. package/dist/api/routes/sign-up.mjs +3 -10
  6. package/dist/api/routes/update-user.mjs +7 -20
  7. package/dist/client/config.mjs +3 -4
  8. package/dist/client/query-atom.mjs +13 -0
  9. package/dist/client/query.mjs +37 -27
  10. package/dist/client/react/react-store.mjs +2 -1
  11. package/dist/client/session-atom.mjs +2 -1
  12. package/dist/context/create-context.mjs +3 -2
  13. package/dist/crypto/purpose.mjs +19 -0
  14. package/dist/index.mjs +1 -0
  15. package/dist/oauth2/index.d.mts +2 -2
  16. package/dist/oauth2/index.mjs +2 -2
  17. package/dist/oauth2/link-account.d.mts +42 -14
  18. package/dist/oauth2/link-account.mjs +61 -1
  19. package/dist/package.mjs +1 -1
  20. package/dist/plugins/admin/admin.mjs +3 -1
  21. package/dist/plugins/admin/routes.mjs +4 -10
  22. package/dist/plugins/admin/types.d.mts +4 -1
  23. package/dist/plugins/captcha/constants.d.mts +1 -0
  24. package/dist/plugins/captcha/constants.mjs +2 -1
  25. package/dist/plugins/captcha/index.d.mts +2 -2
  26. package/dist/plugins/captcha/index.mjs +6 -0
  27. package/dist/plugins/captcha/types.d.mts +44 -2
  28. package/dist/plugins/captcha/utils.mjs +11 -1
  29. package/dist/plugins/captcha/verify-handlers/vercel-botid.mjs +27 -0
  30. package/dist/plugins/email-otp/routes.mjs +3 -4
  31. package/dist/plugins/index.d.mts +2 -2
  32. package/dist/plugins/magic-link/index.mjs +23 -4
  33. package/dist/plugins/oauth-proxy/index.d.mts +5 -6
  34. package/dist/plugins/oauth-proxy/index.mjs +30 -15
  35. package/dist/plugins/open-api/index.mjs +1 -0
  36. package/dist/plugins/organization/client.mjs +2 -1
  37. package/dist/plugins/phone-number/routes.mjs +5 -4
  38. package/dist/plugins/username/index.mjs +2 -0
  39. package/dist/state.mjs +9 -6
  40. package/dist/utils/hashing.mjs +1 -1
  41. package/dist/utils/index.mjs +5 -0
  42. package/dist/utils/middleware-response.mjs +4 -1
  43. package/dist/utils/password.mjs +16 -2
  44. package/package.json +9 -8
@@ -65,7 +65,10 @@ function rateLimitResponse(retryAfter) {
65
65
  return new Response(JSON.stringify({ message: "Too many requests. Please try again later." }), {
66
66
  status: 429,
67
67
  statusText: "Too Many Requests",
68
- headers: { "X-Retry-After": retryAfter.toString() }
68
+ headers: {
69
+ "Content-Type": "application/json",
70
+ "X-Retry-After": retryAfter.toString()
71
+ }
69
72
  });
70
73
  }
71
74
  function getRetryAfter(lastRequest, window) {
@@ -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,8 @@ 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";
13
+ import "../../utils/index.mjs";
12
14
  import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
13
15
  import { additionalAuthorizationParamsSchema, supportsIdTokenSignIn, verifyProviderIdToken } from "@better-auth/core/oauth2";
14
16
  import { SocialProviderListEnum } from "@better-auth/core/social-providers";
@@ -192,7 +194,7 @@ const signInSocial = () => createAuthEndpoint("/sign-in/social", {
192
194
  idToken: token
193
195
  },
194
196
  callbackURL: c.body.callbackURL,
195
- disableSignUp: provider.disableImplicitSignUp && !c.body.requestSignUp || provider.disableSignUp,
197
+ disableSignUp: provider.disableImplicitSignUp && !c.body.requestSignUp || provider.options?.disableSignUp || provider.disableSignUp,
196
198
  source: {
197
199
  method: "oauth",
198
200
  oauth: {
@@ -312,6 +314,7 @@ const signInEmail = () => createAuthEndpoint("/sign-in/email", {
312
314
  }
313
315
  const { email, password } = ctx.body;
314
316
  if (!z.email().safeParse(email).success) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.INVALID_EMAIL);
317
+ assertPasswordNotTooLong(ctx, password);
315
318
  const userRecord = await ctx.context.internalAdapter.findUserByEmail(email.toLowerCase(), { includeAccounts: true });
316
319
  const credentialAccount = userRecord?.accounts.find((account) => account.providerId === "credential" && account.accountId === userRecord.user.id);
317
320
  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({
@@ -19,6 +19,7 @@ const resolvePublicAuthUrl = (basePath) => {
19
19
  } catch {}
20
20
  }
21
21
  };
22
+ const matchesSessionSignal = (path) => path === "/sign-out" || path === "/update-user" || path === "/update-session" || path === "/sign-up/email" || path === "/sign-in/email" || path === "/delete-user" || path === "/verify-email" || path === "/revoke-sessions" || path === "/revoke-session" || path === "/revoke-other-sessions" || path === "/change-email" || path === "/change-password";
22
23
  const getClientConfig = (options, loadEnv) => {
23
24
  const isCredentialsSupported = "credentials" in Request.prototype;
24
25
  const baseURL = getBaseURL(options?.baseURL, options?.basePath, void 0, loadEnv) ?? resolvePublicAuthUrl(options?.basePath) ?? "/api/auth";
@@ -72,9 +73,7 @@ const getClientConfig = (options, loadEnv) => {
72
73
  };
73
74
  const atomListeners = [{
74
75
  signal: "$sessionSignal",
75
- matcher(path) {
76
- return path === "/sign-out" || path === "/update-user" || path === "/update-session" || path === "/sign-up/email" || path === "/sign-in/email" || path === "/delete-user" || path === "/verify-email" || path === "/revoke-sessions" || path === "/revoke-session" || path === "/revoke-other-sessions" || path === "/change-email" || path === "/change-password";
77
- },
76
+ matcher: matchesSessionSignal,
78
77
  callback(path) {
79
78
  if (path === "/sign-out") broadcastSessionUpdate("signout");
80
79
  else if (path === "/update-user" || path === "/update-session") broadcastSessionUpdate("updateUser");
@@ -110,4 +109,4 @@ const getClientConfig = (options, loadEnv) => {
110
109
  };
111
110
  };
112
111
  //#endregion
113
- export { getClientConfig };
112
+ export { getClientConfig, matchesSessionSignal };
@@ -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
@@ -0,0 +1,19 @@
1
+ import { hkdf } from "@noble/hashes/hkdf.js";
2
+ import { sha256 } from "@noble/hashes/sha2.js";
3
+ import { bytesToHex, utf8ToBytes } from "@noble/hashes/utils.js";
4
+ //#region src/crypto/purpose.ts
5
+ const salt = utf8ToBytes("better-auth:oauth-encryption:v1");
6
+ function deriveSecret(secret, purpose) {
7
+ return bytesToHex(hkdf(sha256, utf8ToBytes(secret), salt, utf8ToBytes(`better-auth:${purpose}:v1`), 32));
8
+ }
9
+ /** Derive an isolated key for one OAuth ciphertext purpose, retaining rotation versions. */
10
+ function derivePurposeKey(secret, purpose) {
11
+ if (typeof secret === "string") return deriveSecret(secret, purpose);
12
+ return {
13
+ keys: new Map(Array.from(secret.keys, ([version, value]) => [version, deriveSecret(value, purpose)])),
14
+ currentVersion: secret.currentVersion,
15
+ ...secret.legacySecret && { legacySecret: deriveSecret(secret.legacySecret, purpose) }
16
+ };
17
+ }
18
+ //#endregion
19
+ export { derivePurposeKey };
package/dist/index.mjs CHANGED
@@ -2,6 +2,7 @@ import { getBaseURL, getHost, getHostFromSource, getOrigin, getProtocol, getProt
2
2
  import { generateGenericState, parseGenericState } from "./state.mjs";
3
3
  import { generateIdTokenNonce, generateState, parseState } from "./oauth2/state.mjs";
4
4
  import { HIDE_METADATA } from "./utils/hide-metadata.mjs";
5
+ import "./utils/index.mjs";
5
6
  import { APIError } from "./api/index.mjs";
6
7
  import { betterAuth } from "./auth/full.mjs";
7
8
  import { getCurrentAdapter } from "@better-auth/core/context";
@@ -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.7";
3
3
  //#endregion
4
4
  export { version };