better-auth 1.6.12 → 1.6.13

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 (33) hide show
  1. package/dist/api/index.d.mts +4 -46
  2. package/dist/api/routes/account.d.mts +2 -23
  3. package/dist/api/routes/account.mjs +94 -73
  4. package/dist/api/routes/callback.mjs +3 -2
  5. package/dist/api/routes/password.mjs +1 -1
  6. package/dist/api/routes/session.mjs +1 -1
  7. package/dist/api/routes/sign-in.mjs +1 -1
  8. package/dist/api/routes/update-user.mjs +3 -3
  9. package/dist/client/fetch-plugins.mjs +2 -1
  10. package/dist/context/create-context.mjs +10 -14
  11. package/dist/db/internal-adapter.mjs +19 -20
  12. package/dist/oauth2/index.d.mts +2 -2
  13. package/dist/oauth2/index.mjs +3 -3
  14. package/dist/oauth2/link-account.d.mts +27 -1
  15. package/dist/oauth2/link-account.mjs +24 -1
  16. package/dist/package.mjs +1 -1
  17. package/dist/plugins/admin/routes.mjs +3 -3
  18. package/dist/plugins/anonymous/index.mjs +2 -2
  19. package/dist/plugins/email-otp/routes.mjs +1 -1
  20. package/dist/plugins/generic-oauth/routes.mjs +3 -2
  21. package/dist/plugins/mcp/index.mjs +2 -1
  22. package/dist/plugins/oauth-proxy/index.mjs +1 -1
  23. package/dist/plugins/oidc-provider/index.mjs +2 -1
  24. package/dist/plugins/one-tap/client.mjs +9 -2
  25. package/dist/plugins/one-tap/index.mjs +16 -39
  26. package/dist/plugins/organization/routes/crud-org.d.mts +4 -4
  27. package/dist/plugins/organization/routes/crud-org.mjs +2 -2
  28. package/dist/plugins/organization/types.d.mts +3 -3
  29. package/dist/plugins/phone-number/routes.mjs +1 -1
  30. package/dist/plugins/two-factor/backup-codes/index.d.mts +4 -3
  31. package/dist/plugins/two-factor/client.mjs +2 -1
  32. package/dist/test-utils/test-instance.d.mts +12 -138
  33. package/package.json +8 -8
@@ -1931,29 +1931,6 @@ declare function getEndpoints<Option extends BetterAuthOptions>(ctx: Awaitable<A
1931
1931
  }>;
1932
1932
  readonly accountInfo: better_call0.StrictEndpoint<"/account-info", {
1933
1933
  method: "GET";
1934
- use: ((inputContext: better_call0.MiddlewareInputContext<better_call0.MiddlewareOptions>) => Promise<{
1935
- session: {
1936
- session: Record<string, any> & {
1937
- id: string;
1938
- createdAt: Date;
1939
- updatedAt: Date;
1940
- userId: string;
1941
- expiresAt: Date;
1942
- token: string;
1943
- ipAddress?: string | null | undefined;
1944
- userAgent?: string | null | undefined;
1945
- };
1946
- user: Record<string, any> & {
1947
- id: string;
1948
- createdAt: Date;
1949
- updatedAt: Date;
1950
- email: string;
1951
- emailVerified: boolean;
1952
- name: string;
1953
- image?: string | null | undefined;
1954
- };
1955
- };
1956
- }>)[];
1957
1934
  metadata: {
1958
1935
  openapi: {
1959
1936
  description: string;
@@ -2003,6 +1980,8 @@ declare function getEndpoints<Option extends BetterAuthOptions>(ctx: Awaitable<A
2003
1980
  };
2004
1981
  query: zod.ZodOptional<zod.ZodObject<{
2005
1982
  accountId: zod.ZodOptional<zod.ZodString>;
1983
+ providerId: zod.ZodOptional<zod.ZodString>;
1984
+ userId: zod.ZodOptional<zod.ZodString>;
2006
1985
  }, zod_v4_core0.$strip>>;
2007
1986
  }, {
2008
1987
  user: _better_auth_core_oauth20.OAuth2UserInfo;
@@ -3922,29 +3901,6 @@ declare const router: <Option extends BetterAuthOptions>(ctx: AuthContext, optio
3922
3901
  }>;
3923
3902
  readonly accountInfo: better_call0.StrictEndpoint<"/account-info", {
3924
3903
  method: "GET";
3925
- use: ((inputContext: better_call0.MiddlewareInputContext<better_call0.MiddlewareOptions>) => Promise<{
3926
- session: {
3927
- session: Record<string, any> & {
3928
- id: string;
3929
- createdAt: Date;
3930
- updatedAt: Date;
3931
- userId: string;
3932
- expiresAt: Date;
3933
- token: string;
3934
- ipAddress?: string | null | undefined;
3935
- userAgent?: string | null | undefined;
3936
- };
3937
- user: Record<string, any> & {
3938
- id: string;
3939
- createdAt: Date;
3940
- updatedAt: Date;
3941
- email: string;
3942
- emailVerified: boolean;
3943
- name: string;
3944
- image?: string | null | undefined;
3945
- };
3946
- };
3947
- }>)[];
3948
3904
  metadata: {
3949
3905
  openapi: {
3950
3906
  description: string;
@@ -3994,6 +3950,8 @@ declare const router: <Option extends BetterAuthOptions>(ctx: AuthContext, optio
3994
3950
  };
3995
3951
  query: zod.ZodOptional<zod.ZodObject<{
3996
3952
  accountId: zod.ZodOptional<zod.ZodString>;
3953
+ providerId: zod.ZodOptional<zod.ZodString>;
3954
+ userId: zod.ZodOptional<zod.ZodString>;
3997
3955
  }, zod_v4_core0.$strip>>;
3998
3956
  }, {
3999
3957
  user: _better_auth_core_oauth20.OAuth2UserInfo;
@@ -328,29 +328,6 @@ declare const refreshToken: better_call0.StrictEndpoint<"/refresh-token", {
328
328
  }>;
329
329
  declare const accountInfo: better_call0.StrictEndpoint<"/account-info", {
330
330
  method: "GET";
331
- use: ((inputContext: better_call0.MiddlewareInputContext<better_call0.MiddlewareOptions>) => Promise<{
332
- session: {
333
- session: Record<string, any> & {
334
- id: string;
335
- createdAt: Date;
336
- updatedAt: Date;
337
- userId: string;
338
- expiresAt: Date;
339
- token: string;
340
- ipAddress?: string | null | undefined;
341
- userAgent?: string | null | undefined;
342
- };
343
- user: Record<string, any> & {
344
- id: string;
345
- createdAt: Date;
346
- updatedAt: Date;
347
- email: string;
348
- emailVerified: boolean;
349
- name: string;
350
- image?: string | null | undefined;
351
- };
352
- };
353
- }>)[];
354
331
  metadata: {
355
332
  openapi: {
356
333
  description: string;
@@ -400,6 +377,8 @@ declare const accountInfo: better_call0.StrictEndpoint<"/account-info", {
400
377
  };
401
378
  query: z.ZodOptional<z.ZodObject<{
402
379
  accountId: z.ZodOptional<z.ZodString>;
380
+ providerId: z.ZodOptional<z.ZodString>;
381
+ userId: z.ZodOptional<z.ZodString>;
403
382
  }, z.core.$strip>>;
404
383
  }, {
405
384
  user: _better_auth_core_oauth20.OAuth2UserInfo;
@@ -2,8 +2,9 @@ import { parseAccountOutput } from "../../db/schema.mjs";
2
2
  import { getAccountCookie, setAccountCookie } from "../../cookies/session-store.mjs";
3
3
  import { getAwaitableValue } from "../../context/helpers.mjs";
4
4
  import { missingEmailLogMessage } from "../../oauth2/errors.mjs";
5
- import { generateState } from "../../oauth2/state.mjs";
6
5
  import { decryptOAuthToken, setTokenUtil } from "../../oauth2/utils.mjs";
6
+ import { applyUpdateUserInfoOnLink } from "../../oauth2/link-account.mjs";
7
+ import { generateState } from "../../oauth2/state.mjs";
7
8
  import { freshSessionMiddleware, getSessionFromCtx, sessionMiddleware } from "./session.mjs";
8
9
  import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
9
10
  import { SocialProviderListEnum } from "@better-auth/core/social-providers";
@@ -166,14 +167,7 @@ const linkSocialAccount = createAuthEndpoint("/link-social", {
166
167
  code: "LINKING_FAILED"
167
168
  });
168
169
  }
169
- if (c.context.options.account?.accountLinking?.updateUserInfoOnLink === true) try {
170
- await c.context.internalAdapter.updateUser(session.user.id, {
171
- name: linkingUserInfo.user?.name,
172
- image: linkingUserInfo.user?.image
173
- });
174
- } catch (e) {
175
- console.warn("Could not update user - " + e.toString());
176
- }
170
+ await applyUpdateUserInfoOnLink(c, session.user.id, linkingUserInfo.user);
177
171
  return c.json({
178
172
  url: "",
179
173
  status: true,
@@ -222,50 +216,44 @@ const unlinkAccount = createAuthEndpoint("/unlink-account", {
222
216
  await ctx.context.internalAdapter.deleteAccount(accountExist.id);
223
217
  return ctx.json({ status: true });
224
218
  });
225
- const getAccessToken = createAuthEndpoint("/get-access-token", {
226
- method: "POST",
227
- body: z.object({
228
- providerId: z.string().meta({ description: "The provider ID for the OAuth provider" }),
229
- accountId: z.string().meta({ description: "The account ID associated with the refresh token" }).optional(),
230
- userId: z.string().meta({ description: "The user ID associated with the account" }).optional()
231
- }),
232
- metadata: { openapi: {
233
- description: "Get a valid access token, doing a refresh if needed",
234
- responses: {
235
- 200: {
236
- description: "A Valid access token",
237
- content: { "application/json": { schema: {
238
- type: "object",
239
- properties: {
240
- tokenType: { type: "string" },
241
- idToken: { type: "string" },
242
- accessToken: { type: "string" },
243
- accessTokenExpiresAt: {
244
- type: "string",
245
- format: "date-time"
246
- }
247
- }
248
- } } }
249
- },
250
- 400: { description: "Invalid refresh token or provider configuration" }
251
- }
252
- } }
253
- }, async (ctx) => {
254
- const { providerId, accountId, userId } = ctx.body || {};
255
- const req = ctx.request;
219
+ /**
220
+ * Resolves the user id an account-token operation should act on.
221
+ *
222
+ * A caller reaching the server over HTTP (a request or session headers are
223
+ * present) must have a valid session, and that session's user always wins.
224
+ * A trusted server-side `auth.api` caller with no session may instead name a
225
+ * `userId` directly. Throws `UNAUTHORIZED` when an HTTP caller is
226
+ * unauthenticated, and `USER_ID_OR_SESSION_REQUIRED` when neither a session
227
+ * nor a `userId` is available.
228
+ */
229
+ async function resolveUserId(ctx, userId) {
256
230
  const session = await getSessionFromCtx(ctx);
257
- if (req && !session) throw ctx.error("UNAUTHORIZED");
231
+ if (!session && (ctx.request || ctx.headers)) throw ctx.error("UNAUTHORIZED");
258
232
  const resolvedUserId = session?.user?.id || userId;
259
- if (!resolvedUserId) throw ctx.error("UNAUTHORIZED");
233
+ if (!resolvedUserId) throw APIError.from("BAD_REQUEST", {
234
+ message: "Either userId or session is required",
235
+ code: "USER_ID_OR_SESSION_REQUIRED"
236
+ });
237
+ return resolvedUserId;
238
+ }
239
+ /**
240
+ * Fetches a currently-valid access token for a user's provider account,
241
+ * refreshing and persisting it when it is within five seconds of expiry.
242
+ * Shared by the `/get-access-token` endpoint and `/account-info` so both
243
+ * resolve and refresh tokens through one path.
244
+ */
245
+ async function getValidAccessToken(ctx, { resolvedUserId, providerId, accountId, account: resolvedAccount }) {
260
246
  const provider = await getAwaitableValue(ctx.context.socialProviders, { value: providerId });
261
247
  if (!provider) throw APIError.from("BAD_REQUEST", {
262
248
  message: `Provider ${providerId} is not supported.`,
263
249
  code: "PROVIDER_NOT_SUPPORTED"
264
250
  });
265
- const accountData = await getAccountCookie(ctx);
266
- let account = void 0;
267
- if (accountData && accountData.userId === resolvedUserId && providerId === accountData.providerId && (!accountId || accountData.accountId === accountId)) account = accountData;
268
- else account = (await ctx.context.internalAdapter.findAccounts(resolvedUserId)).find((acc) => accountId ? acc.accountId === accountId && acc.providerId === providerId : acc.providerId === providerId);
251
+ let account = resolvedAccount;
252
+ if (!account) {
253
+ const accountData = await getAccountCookie(ctx);
254
+ if (accountData && accountData.userId === resolvedUserId && providerId === accountData.providerId && (!accountId || accountData.accountId === accountId)) account = accountData;
255
+ else account = (await ctx.context.internalAdapter.findAccounts(resolvedUserId)).find((acc) => accountId ? acc.accountId === accountId && acc.providerId === providerId : acc.providerId === providerId);
256
+ }
269
257
  if (!account) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.ACCOUNT_NOT_FOUND);
270
258
  try {
271
259
  let newTokens = null;
@@ -297,19 +285,55 @@ const getAccessToken = createAuthEndpoint("/get-access-token", {
297
285
  return account.accessTokenExpiresAt;
298
286
  }
299
287
  })();
300
- const tokens = {
288
+ return {
301
289
  accessToken: newTokens?.accessToken ?? await decryptOAuthToken(account.accessToken ?? "", ctx.context),
302
290
  accessTokenExpiresAt,
303
291
  scopes: account.scope?.split(",") ?? [],
304
292
  idToken: newTokens?.idToken ?? account.idToken ?? void 0
305
293
  };
306
- return ctx.json(tokens);
307
294
  } catch (_error) {
308
295
  throw APIError.from("BAD_REQUEST", {
309
296
  message: "Failed to get a valid access token",
310
297
  code: "FAILED_TO_GET_ACCESS_TOKEN"
311
298
  });
312
299
  }
300
+ }
301
+ const getAccessToken = createAuthEndpoint("/get-access-token", {
302
+ method: "POST",
303
+ body: z.object({
304
+ providerId: z.string().meta({ description: "The provider ID for the OAuth provider" }),
305
+ accountId: z.string().meta({ description: "The account ID associated with the refresh token" }).optional(),
306
+ userId: z.string().meta({ description: "The user ID associated with the account" }).optional()
307
+ }),
308
+ metadata: { openapi: {
309
+ description: "Get a valid access token, doing a refresh if needed",
310
+ responses: {
311
+ 200: {
312
+ description: "A Valid access token",
313
+ content: { "application/json": { schema: {
314
+ type: "object",
315
+ properties: {
316
+ tokenType: { type: "string" },
317
+ idToken: { type: "string" },
318
+ accessToken: { type: "string" },
319
+ accessTokenExpiresAt: {
320
+ type: "string",
321
+ format: "date-time"
322
+ }
323
+ }
324
+ } } }
325
+ },
326
+ 400: { description: "Invalid refresh token or provider configuration" }
327
+ }
328
+ } }
329
+ }, async (ctx) => {
330
+ const { providerId, accountId, userId } = ctx.body || {};
331
+ const tokens = await getValidAccessToken(ctx, {
332
+ resolvedUserId: await resolveUserId(ctx, userId),
333
+ providerId,
334
+ accountId
335
+ });
336
+ return ctx.json(tokens);
313
337
  });
314
338
  const refreshToken = createAuthEndpoint("/refresh-token", {
315
339
  method: "POST",
@@ -346,14 +370,7 @@ const refreshToken = createAuthEndpoint("/refresh-token", {
346
370
  } }
347
371
  }, async (ctx) => {
348
372
  const { providerId, accountId, userId } = ctx.body;
349
- const req = ctx.request;
350
- const session = await getSessionFromCtx(ctx);
351
- if (req && !session) throw ctx.error("UNAUTHORIZED");
352
- const resolvedUserId = session?.user?.id || userId;
353
- if (!resolvedUserId) throw APIError.from("BAD_REQUEST", {
354
- message: `Either userId or session is required`,
355
- code: "USER_ID_OR_SESSION_REQUIRED"
356
- });
373
+ const resolvedUserId = await resolveUserId(ctx, userId);
357
374
  const provider = await getAwaitableValue(ctx.context.socialProviders, { value: providerId });
358
375
  if (!provider) throw APIError.from("BAD_REQUEST", {
359
376
  message: `Provider ${providerId} is not supported.`,
@@ -418,10 +435,13 @@ const refreshToken = createAuthEndpoint("/refresh-token", {
418
435
  });
419
436
  }
420
437
  });
421
- const accountInfoQuerySchema = z.optional(z.object({ accountId: z.string().meta({ description: "The provider given account id for which to get the account info" }).optional() }));
438
+ const accountInfoQuerySchema = z.optional(z.object({
439
+ accountId: z.string().meta({ description: "The provider given account id for which to get the account info" }).optional(),
440
+ providerId: z.string().meta({ description: "The provider ID to disambiguate provider-issued account IDs" }).optional(),
441
+ userId: z.string().meta({ description: "The user ID associated with the account" }).optional()
442
+ }));
422
443
  const accountInfo = createAuthEndpoint("/account-info", {
423
444
  method: "GET",
424
- use: [sessionMiddleware],
425
445
  metadata: { openapi: {
426
446
  description: "Get the account info provided by the provider",
427
447
  responses: { "200": {
@@ -453,7 +473,8 @@ const accountInfo = createAuthEndpoint("/account-info", {
453
473
  } },
454
474
  query: accountInfoQuerySchema
455
475
  }, async (ctx) => {
456
- const providedAccountId = ctx.query?.accountId;
476
+ const { accountId: providedAccountId, providerId: providedProviderId, userId } = ctx.query || {};
477
+ const resolvedUserId = await resolveUserId(ctx, userId);
457
478
  let account = void 0;
458
479
  if (!providedAccountId) {
459
480
  if (ctx.context.options.account?.storeAccountCookie) {
@@ -461,24 +482,24 @@ const accountInfo = createAuthEndpoint("/account-info", {
461
482
  if (accountData) account = accountData;
462
483
  }
463
484
  } else {
464
- const accountData = await ctx.context.internalAdapter.findAccount(providedAccountId);
465
- if (accountData) account = accountData;
485
+ const matchingAccounts = (await ctx.context.internalAdapter.findAccounts(resolvedUserId)).filter((acc) => acc.accountId === providedAccountId && (!providedProviderId || acc.providerId === providedProviderId));
486
+ if (matchingAccounts.length > 1) throw APIError.from("BAD_REQUEST", {
487
+ message: "Multiple accounts share this account ID. Pass a providerId to disambiguate.",
488
+ code: "AMBIGUOUS_ACCOUNT"
489
+ });
490
+ account = matchingAccounts[0];
466
491
  }
467
- if (!account || account.userId !== ctx.context.session.user.id) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.ACCOUNT_NOT_FOUND);
492
+ if (!account || account.userId !== resolvedUserId) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.ACCOUNT_NOT_FOUND);
468
493
  const provider = await getAwaitableValue(ctx.context.socialProviders, { value: account.providerId });
469
- if (!provider) throw APIError.from("INTERNAL_SERVER_ERROR", {
470
- message: `Provider account provider is ${account.providerId} but it is not configured`,
494
+ if (!provider) throw APIError.from("BAD_REQUEST", {
495
+ message: "Account is not associated with a configured social provider.",
471
496
  code: "PROVIDER_NOT_CONFIGURED"
472
497
  });
473
- const tokens = await getAccessToken({
474
- ...ctx,
475
- method: "POST",
476
- body: {
477
- accountId: account.accountId,
478
- providerId: account.providerId
479
- },
480
- returnHeaders: false,
481
- returnStatus: false
498
+ const tokens = await getValidAccessToken(ctx, {
499
+ resolvedUserId,
500
+ providerId: account.providerId,
501
+ accountId: account.accountId,
502
+ account
482
503
  });
483
504
  if (!tokens.accessToken) throw APIError.from("BAD_REQUEST", {
484
505
  message: "Access token not found",
@@ -2,9 +2,9 @@ import { isAPIError } from "../../utils/is-api-error.mjs";
2
2
  import { setSessionCookie } from "../../cookies/index.mjs";
3
3
  import { getAwaitableValue } from "../../context/helpers.mjs";
4
4
  import { missingEmailLogMessage, redirectOnError } from "../../oauth2/errors.mjs";
5
- import { parseState } from "../../oauth2/state.mjs";
6
5
  import { setTokenUtil } from "../../oauth2/utils.mjs";
7
- import { handleOAuthUserInfo } from "../../oauth2/link-account.mjs";
6
+ import { applyUpdateUserInfoOnLink, handleOAuthUserInfo } from "../../oauth2/link-account.mjs";
7
+ import { parseState } from "../../oauth2/state.mjs";
8
8
  import { HIDE_METADATA } from "../../utils/hide-metadata.mjs";
9
9
  import { safeJSONParse } from "@better-auth/core/utils/json";
10
10
  import { createAuthEndpoint } from "@better-auth/core/api";
@@ -117,6 +117,7 @@ const callbackOAuth = createAuthEndpoint("/callback/:id", {
117
117
  refreshToken: await setTokenUtil(tokens.refreshToken, c.context),
118
118
  scope: tokens.scopes?.join(",")
119
119
  })) redirectOnError(c, resolvedErrorURL, "unable_to_link_account");
120
+ await applyUpdateUserInfoOnLink(c, link.userId, userInfo);
120
121
  let toRedirectTo;
121
122
  try {
122
123
  toRedirectTo = callbackURL.toString();
@@ -161,7 +161,7 @@ const resetPassword = createAuthEndpoint("/reset-password", {
161
161
  const user = await ctx.context.internalAdapter.findUserById(userId);
162
162
  if (user) await ctx.context.options.emailAndPassword.onPasswordReset({ user }, ctx.request);
163
163
  }
164
- if (ctx.context.options.emailAndPassword?.revokeSessionsOnPasswordReset) await ctx.context.internalAdapter.deleteSessions(userId);
164
+ if (ctx.context.options.emailAndPassword?.revokeSessionsOnPasswordReset) await ctx.context.internalAdapter.deleteUserSessions(userId);
165
165
  return ctx.json({ status: true });
166
166
  });
167
167
  const verifyPassword = createAuthEndpoint("/verify-password", {
@@ -445,7 +445,7 @@ const revokeSessions = createAuthEndpoint("/revoke-sessions", {
445
445
  } }
446
446
  }, async (ctx) => {
447
447
  try {
448
- await ctx.context.internalAdapter.deleteSessions(ctx.context.session.user.id);
448
+ await ctx.context.internalAdapter.deleteUserSessions(ctx.context.session.user.id);
449
449
  } catch (error) {
450
450
  ctx.context.logger.error(error && typeof error === "object" && "name" in error ? error.name : "", error);
451
451
  throw APIError.from("INTERNAL_SERVER_ERROR", {
@@ -3,8 +3,8 @@ import { parseUserOutput } from "../../db/schema.mjs";
3
3
  import { setSessionCookie } from "../../cookies/index.mjs";
4
4
  import { getAwaitableValue } from "../../context/helpers.mjs";
5
5
  import { missingEmailLogMessage } from "../../oauth2/errors.mjs";
6
- import { generateState } from "../../oauth2/state.mjs";
7
6
  import { handleOAuthUserInfo } from "../../oauth2/link-account.mjs";
7
+ import { generateState } from "../../oauth2/state.mjs";
8
8
  import { createEmailVerificationToken } from "./email-verification.mjs";
9
9
  import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
10
10
  import { SocialProviderListEnum } from "@better-auth/core/social-providers";
@@ -168,7 +168,7 @@ const changePassword = createAuthEndpoint("/change-password", {
168
168
  await ctx.context.internalAdapter.updateAccount(account.id, { password: passwordHash });
169
169
  let token = null;
170
170
  if (revokeOtherSessions) {
171
- await ctx.context.internalAdapter.deleteSessions(session.user.id);
171
+ await ctx.context.internalAdapter.deleteUserSessions(session.user.id);
172
172
  const newSession = await ctx.context.internalAdapter.createSession(session.user.id);
173
173
  if (!newSession) throw APIError.from("INTERNAL_SERVER_ERROR", BASE_ERROR_CODES.FAILED_TO_GET_SESSION);
174
174
  await setSessionCookie(ctx, {
@@ -309,7 +309,7 @@ const deleteUser = createAuthEndpoint("/delete-user", {
309
309
  const beforeDelete = ctx.context.options.user.deleteUser?.beforeDelete;
310
310
  if (beforeDelete) await beforeDelete(session.user, ctx.request);
311
311
  await ctx.context.internalAdapter.deleteUser(session.user.id);
312
- await ctx.context.internalAdapter.deleteSessions(session.user.id);
312
+ await ctx.context.internalAdapter.deleteUserSessions(session.user.id);
313
313
  deleteSessionCookie(ctx);
314
314
  const afterDelete = ctx.context.options.user.deleteUser?.afterDelete;
315
315
  if (afterDelete) await afterDelete(session.user, ctx.request);
@@ -362,7 +362,7 @@ const deleteUserCallback = createAuthEndpoint("/delete-user/callback", {
362
362
  const beforeDelete = ctx.context.options.user.deleteUser?.beforeDelete;
363
363
  if (beforeDelete) await beforeDelete(session.user, ctx.request);
364
364
  await ctx.context.internalAdapter.deleteUser(session.user.id);
365
- await ctx.context.internalAdapter.deleteSessions(session.user.id);
365
+ await ctx.context.internalAdapter.deleteUserSessions(session.user.id);
366
366
  await ctx.context.internalAdapter.deleteAccounts(session.user.id);
367
367
  await ctx.context.internalAdapter.deleteVerificationByIdentifier(`delete-account-${ctx.query.token}`);
368
368
  deleteSessionCookie(ctx);
@@ -1,9 +1,10 @@
1
+ import { isSafeUrlScheme } from "@better-auth/core/utils/url";
1
2
  //#region src/client/fetch-plugins.ts
2
3
  const redirectPlugin = {
3
4
  id: "redirect",
4
5
  name: "Redirect",
5
6
  hooks: { onSuccess(context) {
6
- if (context.data?.url && context.data?.redirect) {
7
+ if (context.data?.url && context.data?.redirect && isSafeUrlScheme(context.data.url)) {
7
8
  if (typeof window !== "undefined" && window.location) {
8
9
  if (window.location) try {
9
10
  window.location.href = context.data.url;
@@ -42,18 +42,14 @@ function validateSecret(secret, logger) {
42
42
  if (estimateEntropy(secret) < 120) logger.warn("[better-auth] Warning: your BETTER_AUTH_SECRET appears low-entropy. Use a randomly generated secret for production.");
43
43
  }
44
44
  async function createAuthContext(adapter, options, getDatabaseType) {
45
- if (!options.database) options = defu$1(options, {
46
- session: { cookieCache: {
47
- enabled: true,
48
- strategy: "jwe",
49
- refreshCache: true,
50
- maxAge: options.session?.expiresIn || 3600 * 24 * 7
51
- } },
52
- account: {
53
- storeStateStrategy: "cookie",
54
- storeAccountCookie: true
55
- }
56
- });
45
+ const isStateful = !!options.database || !!options.secondaryStorage;
46
+ if (!isStateful) options = defu$1(options, { session: { cookieCache: {
47
+ enabled: true,
48
+ strategy: "jwe",
49
+ refreshCache: true,
50
+ maxAge: options.session?.expiresIn || 3600 * 24 * 7
51
+ } } });
52
+ if (!options.database) options = defu$1(options, { account: { storeAccountCookie: true } });
57
53
  const plugins = options.plugins || [];
58
54
  const internalPlugins = getInternalPlugins(options);
59
55
  const logger = createLogger(options.logger);
@@ -130,7 +126,7 @@ Most of the features of Better Auth will not work correctly.`);
130
126
  socialProviders: providers,
131
127
  options,
132
128
  oauthConfig: {
133
- storeStateStrategy: options.account?.storeStateStrategy || (options.database ? "database" : "cookie"),
129
+ storeStateStrategy: options.account?.storeStateStrategy || (isStateful ? "database" : "cookie"),
134
130
  skipStateCookieCheck: !!options.account?.skipStateCookieCheck
135
131
  },
136
132
  tables,
@@ -146,7 +142,7 @@ Most of the features of Better Auth will not work correctly.`);
146
142
  cookieRefreshCache: (() => {
147
143
  const refreshCache = options.session?.cookieCache?.refreshCache;
148
144
  const maxAge = options.session?.cookieCache?.maxAge || 300;
149
- if ((!!options.database || !!options.secondaryStorage) && refreshCache) {
145
+ if (isStateful && refreshCache) {
150
146
  logger.warn("[better-auth] `session.cookieCache.refreshCache` is enabled while `database` or `secondaryStorage` is configured. `refreshCache` is meant for stateless (DB-less) setups. Disabling `refreshCache` — remove it from your config to silence this warning.");
151
147
  return false;
152
148
  }
@@ -388,21 +388,29 @@ const createInternalAdapter = (adapter, ctx) => {
388
388
  value: id
389
389
  }], "account", void 0);
390
390
  },
391
- deleteSessions: async (userIdOrSessionTokens) => {
391
+ deleteUserSessions: async (userId) => {
392
392
  if (secondaryStorage) {
393
- if (typeof userIdOrSessionTokens === "string") {
394
- const activeSession = await secondaryStorage.get(`active-sessions-${userIdOrSessionTokens}`);
395
- const sessions = activeSession ? safeJSONParse(activeSession) : [];
396
- if (!sessions) return;
397
- for (const session of sessions) await secondaryStorage.delete(session.token);
398
- await secondaryStorage.delete(`active-sessions-${userIdOrSessionTokens}`);
399
- } else for (const sessionToken of userIdOrSessionTokens) if (await secondaryStorage.get(sessionToken)) await secondaryStorage.delete(sessionToken);
393
+ const activeSession = await secondaryStorage.get(`active-sessions-${userId}`);
394
+ const sessions = activeSession ? safeJSONParse(activeSession) : [];
395
+ if (!sessions) return;
396
+ for (const session of sessions) await secondaryStorage.delete(session.token);
397
+ await secondaryStorage.delete(`active-sessions-${userId}`);
400
398
  if (!options.session?.storeSessionInDatabase || ctx.options.session?.preserveSessionInDatabase) return;
401
399
  }
402
400
  await deleteManyWithHooks([{
403
- field: Array.isArray(userIdOrSessionTokens) ? "token" : "userId",
404
- value: userIdOrSessionTokens,
405
- operator: Array.isArray(userIdOrSessionTokens) ? "in" : void 0
401
+ field: "userId",
402
+ value: userId
403
+ }], "session", void 0);
404
+ },
405
+ deleteSessions: async (sessionTokens) => {
406
+ if (secondaryStorage) {
407
+ for (const sessionToken of sessionTokens) if (await secondaryStorage.get(sessionToken)) await secondaryStorage.delete(sessionToken);
408
+ if (!options.session?.storeSessionInDatabase || ctx.options.session?.preserveSessionInDatabase) return;
409
+ }
410
+ await deleteManyWithHooks([{
411
+ field: "token",
412
+ value: sessionTokens,
413
+ operator: "in"
406
414
  }], "session", void 0);
407
415
  },
408
416
  findOAuthUser: async (email, accountId, providerId) => {
@@ -532,15 +540,6 @@ const createInternalAdapter = (adapter, ctx) => {
532
540
  }]
533
541
  });
534
542
  },
535
- findAccount: async (accountId) => {
536
- return await (await getCurrentAdapter(adapter)).findOne({
537
- model: "account",
538
- where: [{
539
- field: "accountId",
540
- value: accountId
541
- }]
542
- });
543
- },
544
543
  findAccountByProviderId: async (accountId, providerId) => {
545
544
  return await (await getCurrentAdapter(adapter)).findOne({
546
545
  model: "account",
@@ -1,5 +1,5 @@
1
1
  import { generateState, parseState } from "./state.mjs";
2
- import { handleOAuthUserInfo } from "./link-account.mjs";
2
+ import { applyUpdateUserInfoOnLink, handleOAuthUserInfo } from "./link-account.mjs";
3
3
  import { decryptOAuthToken, setTokenUtil } from "./utils.mjs";
4
4
  export * from "@better-auth/core/oauth2";
5
- export { decryptOAuthToken, generateState, handleOAuthUserInfo, parseState, setTokenUtil };
5
+ export { applyUpdateUserInfoOnLink, decryptOAuthToken, generateState, handleOAuthUserInfo, parseState, setTokenUtil };
@@ -1,5 +1,5 @@
1
- import { generateState, parseState } from "./state.mjs";
2
1
  import { decryptOAuthToken, setTokenUtil } from "./utils.mjs";
3
- import { handleOAuthUserInfo } from "./link-account.mjs";
2
+ import { applyUpdateUserInfoOnLink, handleOAuthUserInfo } from "./link-account.mjs";
3
+ import { generateState, parseState } from "./state.mjs";
4
4
  export * from "@better-auth/core/oauth2";
5
- export { decryptOAuthToken, generateState, handleOAuthUserInfo, parseState, setTokenUtil };
5
+ export { applyUpdateUserInfoOnLink, decryptOAuthToken, generateState, handleOAuthUserInfo, parseState, setTokenUtil };
@@ -42,5 +42,31 @@ declare function handleOAuthUserInfo(c: GenericEndpointContext, opts: {
42
42
  error: null;
43
43
  isRegister: boolean;
44
44
  }>;
45
+ /**
46
+ * Provider profile a freshly linked account may copy onto the local user.
47
+ * `id` is the provider's account id (never the local user id), and `email`/
48
+ * `emailVerified` are identity anchors; all three are stripped before the
49
+ * remaining fields are written.
50
+ */
51
+ type LinkedProviderProfile = {
52
+ id: string | number;
53
+ name?: string | undefined;
54
+ email?: string | null | undefined;
55
+ emailVerified?: boolean | undefined;
56
+ image?: string | null | undefined;
57
+ };
58
+ /**
59
+ * Apply the `account.accountLinking.updateUserInfoOnLink` policy: when enabled,
60
+ * copy the freshly linked provider's profile onto the local user, matching the
61
+ * field set persisted on sign-up. The local `email` and `emailVerified` are
62
+ * never changed, so a link can't rebind the account's identity, and
63
+ * `updateUser` drops `undefined` fields, so a provider that omits one leaves
64
+ * the existing column intact.
65
+ *
66
+ * Returns the updated user so a caller that issues a session can seed the
67
+ * cookie cache with the fresh row. Returns `undefined` when the policy is
68
+ * disabled or the update fails: a failed profile sync must not abort the link.
69
+ */
70
+ declare function applyUpdateUserInfoOnLink(c: GenericEndpointContext, userId: string, userInfo: LinkedProviderProfile): Promise<User | undefined>;
45
71
  //#endregion
46
- export { handleOAuthUserInfo };
72
+ export { applyUpdateUserInfoOnLink, handleOAuthUserInfo };
@@ -46,6 +46,7 @@ async function handleOAuthUserInfo(c, opts) {
46
46
  };
47
47
  }
48
48
  if (userInfo.emailVerified && !dbUser.user.emailVerified && userInfo.email.toLowerCase() === dbUser.user.email) await c.context.internalAdapter.updateUser(dbUser.user.id, { emailVerified: true });
49
+ user = await applyUpdateUserInfoOnLink(c, dbUser.user.id, userInfo) ?? user;
49
50
  } else {
50
51
  const freshTokens = c.context.options.account?.updateAccountOnSignIn !== false ? Object.fromEntries(Object.entries({
51
52
  idToken: account.idToken,
@@ -137,5 +138,27 @@ async function handleOAuthUserInfo(c, opts) {
137
138
  isRegister
138
139
  };
139
140
  }
141
+ /**
142
+ * Apply the `account.accountLinking.updateUserInfoOnLink` policy: when enabled,
143
+ * copy the freshly linked provider's profile onto the local user, matching the
144
+ * field set persisted on sign-up. The local `email` and `emailVerified` are
145
+ * never changed, so a link can't rebind the account's identity, and
146
+ * `updateUser` drops `undefined` fields, so a provider that omits one leaves
147
+ * the existing column intact.
148
+ *
149
+ * Returns the updated user so a caller that issues a session can seed the
150
+ * cookie cache with the fresh row. Returns `undefined` when the policy is
151
+ * disabled or the update fails: a failed profile sync must not abort the link.
152
+ */
153
+ async function applyUpdateUserInfoOnLink(c, userId, userInfo) {
154
+ if (c.context.options.account?.accountLinking?.updateUserInfoOnLink !== true) return;
155
+ const { id: _id, email: _email, emailVerified: _emailVerified, ...profile } = userInfo;
156
+ try {
157
+ return await c.context.internalAdapter.updateUser(userId, profile);
158
+ } catch (e) {
159
+ c.context.logger.warn("Could not update user info on account link", e);
160
+ return;
161
+ }
162
+ }
140
163
  //#endregion
141
- export { handleOAuthUserInfo };
164
+ export { applyUpdateUserInfoOnLink, handleOAuthUserInfo };