better-auth 1.8.0-beta.4 → 1.8.0-beta.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.
Files changed (66) hide show
  1. package/dist/api/dispatch.d.mts +1 -0
  2. package/dist/api/dispatch.mjs +30 -7
  3. package/dist/api/index.mjs +1 -1
  4. package/dist/api/routes/account.mjs +14 -6
  5. package/dist/api/routes/email-verification.mjs +29 -37
  6. package/dist/api/routes/sign-in.mjs +2 -2
  7. package/dist/api/routes/sign-up.mjs +1 -1
  8. package/dist/api/routes/update-user.mjs +10 -4
  9. package/dist/auth/trusted-origins.mjs +33 -8
  10. package/dist/client/index.d.mts +2 -1
  11. package/dist/cookies/index.mjs +1 -0
  12. package/dist/db/internal-adapter.mjs +57 -22
  13. package/dist/index.d.mts +2 -2
  14. package/dist/index.mjs +2 -2
  15. package/dist/oauth2/link-account.d.mts +3 -5
  16. package/dist/oauth2/link-account.mjs +33 -3
  17. package/dist/oauth2/state.d.mts +3 -4
  18. package/dist/package.mjs +1 -1
  19. package/dist/plugins/admin/access/statement.d.mts +10 -10
  20. package/dist/plugins/admin/access/statement.mjs +2 -0
  21. package/dist/plugins/admin/admin.d.mts +4 -3
  22. package/dist/plugins/admin/client.d.mts +5 -4
  23. package/dist/plugins/admin/error-codes.d.mts +1 -0
  24. package/dist/plugins/admin/error-codes.mjs +1 -0
  25. package/dist/plugins/admin/routes.mjs +8 -0
  26. package/dist/plugins/anonymous/index.mjs +1 -1
  27. package/dist/plugins/device-authorization/routes.mjs +3 -3
  28. package/dist/plugins/email-otp/routes.mjs +33 -50
  29. package/dist/plugins/generic-oauth/index.mjs +54 -30
  30. package/dist/plugins/index.d.mts +2 -1
  31. package/dist/plugins/index.mjs +2 -1
  32. package/dist/plugins/oauth-proxy/index.mjs +1 -1
  33. package/dist/plugins/one-tap/index.mjs +1 -1
  34. package/dist/plugins/organization/adapter.mjs +1 -0
  35. package/dist/plugins/organization/client.d.mts +1 -0
  36. package/dist/plugins/organization/error-codes.d.mts +1 -0
  37. package/dist/plugins/organization/error-codes.mjs +1 -0
  38. package/dist/plugins/organization/has-permission.d.mts +2 -3
  39. package/dist/plugins/organization/has-permission.mjs +62 -23
  40. package/dist/plugins/organization/index.d.mts +2 -1
  41. package/dist/plugins/organization/index.mjs +2 -1
  42. package/dist/plugins/organization/organization.d.mts +2 -2
  43. package/dist/plugins/organization/organization.mjs +1 -3
  44. package/dist/plugins/organization/permission.mjs +3 -3
  45. package/dist/plugins/organization/roles.d.mts +8 -0
  46. package/dist/plugins/organization/roles.mjs +26 -0
  47. package/dist/plugins/organization/routes/crud-access-control.mjs +2 -1
  48. package/dist/plugins/organization/routes/crud-invites.d.mts +22 -0
  49. package/dist/plugins/organization/routes/crud-invites.mjs +28 -13
  50. package/dist/plugins/organization/routes/crud-members.mjs +10 -14
  51. package/dist/plugins/organization/routes/crud-org.mjs +12 -3
  52. package/dist/plugins/phone-number/client.d.mts +2 -0
  53. package/dist/plugins/phone-number/error-codes.d.mts +2 -0
  54. package/dist/plugins/phone-number/error-codes.mjs +2 -0
  55. package/dist/plugins/phone-number/index.d.mts +25 -1
  56. package/dist/plugins/phone-number/index.mjs +22 -6
  57. package/dist/plugins/phone-number/routes.mjs +128 -62
  58. package/dist/plugins/phone-number/types.d.mts +11 -5
  59. package/dist/plugins/two-factor/index.mjs +7 -4
  60. package/dist/plugins/two-factor/verify-two-factor.mjs +2 -2
  61. package/dist/plugins/username/index.mjs +1 -1
  62. package/dist/state.d.mts +6 -0
  63. package/dist/state.mjs +8 -2
  64. package/dist/test-utils/test-instance.mjs +43 -33
  65. package/dist/utils/index.d.mts +2 -2
  66. package/package.json +11 -11
@@ -1,5 +1,6 @@
1
1
  import { AuthContext } from "@better-auth/core";
2
2
  import { Endpoint, EndpointContext, InputContext } from "better-call";
3
+ import "@better-auth/core/api";
3
4
  //#region src/api/dispatch.d.ts
4
5
  /**
5
6
  * Input accepted by {@link dispatchAuthEndpoint}. `context` must already be a
@@ -6,6 +6,7 @@ import { APIError } from "@better-auth/core/error";
6
6
  import { createDefu } from "defu";
7
7
  import { ATTR_CONTEXT, ATTR_HOOK_TYPE, ATTR_HTTP_ROUTE, ATTR_OPERATION_ID, createWithSpan } from "@better-auth/core/instrumentation";
8
8
  import { kAPIErrorHeaderSymbol, toResponse } from "better-call";
9
+ import { getInternalAuthCompletion } from "@better-auth/core/utils/auth-completion";
9
10
  //#region src/api/dispatch.ts
10
11
  const defuReplaceArrays = createDefu((obj, key, value) => {
11
12
  if (Array.isArray(obj[key]) && Array.isArray(value)) {
@@ -14,6 +15,18 @@ const defuReplaceArrays = createDefu((obj, key, value) => {
14
15
  }
15
16
  });
16
17
  const hooksSourceWeakMap = /* @__PURE__ */ new WeakMap();
18
+ const signInGates = /* @__PURE__ */ new WeakSet();
19
+ /**
20
+ * Runs a plugin after hook ahead of every other plugin after hook, so a sign-in
21
+ * it withholds (such as a pending two-factor challenge) is never observed as
22
+ * complete, regardless of plugin registration order.
23
+ *
24
+ * @internal
25
+ */
26
+ function asSignInGate(hook) {
27
+ signInGates.add(hook.handler);
28
+ return hook;
29
+ }
17
30
  /**
18
31
  * Resolves the operation id used for spans, preferring an explicit
19
32
  * `operationId`, then the OpenAPI one, then the caller's `fallback` (the
@@ -163,12 +176,19 @@ function getHooks(authContext) {
163
176
  const pluginAfterHooks = plugins.flatMap((plugin) => (plugin.hooks?.after ?? []).map((h) => {
164
177
  hooksSourceWeakMap.set(h.handler, `plugin:${plugin.id}`);
165
178
  return h;
166
- }));
179
+ })).sort((a, b) => Number(signInGates.has(b.handler)) - Number(signInGates.has(a.handler)));
180
+ const completionHooks = plugins.flatMap((plugin) => {
181
+ const completion = getInternalAuthCompletion(plugin);
182
+ if (!completion) return [];
183
+ hooksSourceWeakMap.set(completion.handler, `plugin:${plugin.id}`);
184
+ return [completion];
185
+ });
167
186
  if (pluginBeforeHooks.length) beforeHooks.push(...pluginBeforeHooks);
168
187
  if (pluginAfterHooks.length) afterHooks.push(...pluginAfterHooks);
169
188
  return {
170
189
  beforeHooks,
171
- afterHooks
190
+ afterHooks,
191
+ completionHooks
172
192
  };
173
193
  }
174
194
  /**
@@ -208,7 +228,7 @@ async function dispatchAuthEndpoint(endpoint, input) {
208
228
  [ATTR_HTTP_ROUTE]: route,
209
229
  [ATTR_OPERATION_ID]: operationId
210
230
  }, async () => runWithEndpointContext(internalContext, async () => {
211
- const { beforeHooks, afterHooks } = getHooks(internalContext.context);
231
+ const { beforeHooks, afterHooks, completionHooks } = getHooks(internalContext.context);
212
232
  const before = await runBeforeHooks(internalContext, beforeHooks, endpoint, operationId);
213
233
  if ("context" in before && before.context && typeof before.context === "object") {
214
234
  const { headers, ...rest } = before.context;
@@ -244,9 +264,12 @@ async function dispatchAuthEndpoint(endpoint, input) {
244
264
  if (result instanceof Response) return result;
245
265
  internalContext.context.returned = result.response;
246
266
  mergeResponseHeaders(internalContext.context, result.headers);
247
- const after = await runAfterHooks(internalContext, afterHooks, endpoint, operationId);
248
- if (after.response !== void 0) result.response = after.response;
249
- result.headers = after.headers ?? result.headers;
267
+ await runAfterHooks(internalContext, afterHooks, endpoint, operationId);
268
+ const matchingCompletions = completionHooks.filter((hook) => hook.matcher(internalContext));
269
+ if (matchingCompletions.length > 1) throw new APIError("INTERNAL_SERVER_ERROR", { message: "Multiple authentication completion handlers matched." });
270
+ const completed = await runAfterHooks(internalContext, matchingCompletions, endpoint, operationId);
271
+ if (completed.response !== void 0) result.response = completed.response;
272
+ result.headers = completed.headers ?? result.headers;
250
273
  if (isAPIError(result.response) && shouldPublishLog(internalContext.context.logger.level, "debug")) result.response.stack = result.response.errorStack;
251
274
  if (isAPIError(result.response) && !shouldReturnResponse) {
252
275
  if (result.headers) Object.defineProperty(result.response, kAPIErrorHeaderSymbol, {
@@ -274,4 +297,4 @@ async function dispatchAuthEndpoint(endpoint, input) {
274
297
  }));
275
298
  }
276
299
  //#endregion
277
- export { dispatchAuthEndpoint, getOperationId };
300
+ export { asSignInGate, dispatchAuthEndpoint, getOperationId };
@@ -2,9 +2,9 @@ import { isAPIError } from "../utils/is-api-error.mjs";
2
2
  import { requireOrgRole, requireResourceOwnership } from "./middlewares/authorization.mjs";
3
3
  import { formCsrfMiddleware, originCheck, originCheckMiddleware } from "./middlewares/origin-check.mjs";
4
4
  import { onRequestRateLimit } from "./rate-limiter/index.mjs";
5
- import { addOAuthServerContext, getOAuthState } from "./state/oauth.mjs";
6
5
  import { getShouldSkipSessionRefresh, setShouldSkipSessionRefresh } from "./state/should-session-refresh.mjs";
7
6
  import { freshSessionMiddleware, getAuthoritativeSessionFromCtx, getSession, getSessionFromCtx, isStateful, listSessions, requestOnlySessionMiddleware, revokeOtherSessions, revokeSession, revokeSessions, sensitiveSessionMiddleware, sessionMiddleware } from "./routes/session.mjs";
7
+ import { addOAuthServerContext, getOAuthState } from "./state/oauth.mjs";
8
8
  import { accountInfo, getAccessToken, linkSocialAccount, listUserAccounts, refreshToken, unlinkAccount } from "./routes/account.mjs";
9
9
  import { callbackOAuth } from "./routes/callback.mjs";
10
10
  import { createEmailVerificationToken, sendVerificationEmail, sendVerificationEmailFn, verifyEmail } from "./routes/email-verification.mjs";
@@ -1,13 +1,16 @@
1
1
  import { shouldBindAccountCookieToSessionUser } from "../../context/store-capabilities.mjs";
2
2
  import { parseAccountOutput } from "../../db/schema.mjs";
3
+ import { symmetricEncrypt } from "../../crypto/index.mjs";
3
4
  import { getAccountCookie, setAccountCookie } from "../../cookies/session-store.mjs";
4
5
  import { getAwaitableValue } from "../../context/helpers.mjs";
6
+ import { derivePurposeKey } from "../../crypto/purpose.mjs";
5
7
  import { resolveOAuthAccountKeyForAPI } from "../../oauth2/account-key.mjs";
6
8
  import { missingEmailLogMessage } from "../../oauth2/errors.mjs";
9
+ import { freshSessionMiddleware, getSessionFromCtx, isStateful, sensitiveSessionMiddleware, sessionMiddleware } from "./session.mjs";
10
+ import { toBoolean } from "../../utils/boolean.mjs";
7
11
  import { decryptOAuthToken, getOAuthCallbackPath, setTokenUtil } from "../../oauth2/utils.mjs";
8
12
  import { applyUpdateUserInfoOnLink } from "../../oauth2/link-account.mjs";
9
13
  import { generateIdTokenNonce, generateState } from "../../oauth2/state.mjs";
10
- import { freshSessionMiddleware, getSessionFromCtx, isStateful, sessionMiddleware } from "./session.mjs";
11
14
  import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
12
15
  import { additionalAuthorizationParamsSchema, supportsIdTokenSignIn, verifyProviderIdToken } from "@better-auth/core/oauth2";
13
16
  import { SocialProviderListEnum } from "@better-auth/core/social-providers";
@@ -129,7 +132,7 @@ const linkSocialAccount = createAuthEndpoint("/link-social", {
129
132
  */
130
133
  additionalData: z.record(z.string(), z.any()).optional()
131
134
  }),
132
- use: [sessionMiddleware],
135
+ use: [sensitiveSessionMiddleware],
133
136
  metadata: { openapi: {
134
137
  description: "Link a social account to the user",
135
138
  operationId: "linkSocialAccount",
@@ -169,11 +172,12 @@ const linkSocialAccount = createAuthEndpoint("/link-social", {
169
172
  c.context.logger.warn("Invalid id token", { provider: c.body.provider });
170
173
  throw APIError.from("UNAUTHORIZED", BASE_ERROR_CODES.INVALID_TOKEN);
171
174
  }
172
- const linkingUserInfo = await provider.getUserInfo({
175
+ const oauthTokens = {
173
176
  idToken: token,
174
177
  accessToken: c.body.idToken.accessToken,
175
178
  refreshToken: c.body.idToken.refreshToken
176
- });
179
+ };
180
+ const linkingUserInfo = provider.getUserInfoFromIdToken ? await provider.getUserInfoFromIdToken(token) : await provider.getUserInfo(oauthTokens);
177
181
  if (!linkingUserInfo || !linkingUserInfo?.user) {
178
182
  c.context.logger.error("Failed to get user info", { provider: c.body.provider });
179
183
  throw APIError.from("UNAUTHORIZED", BASE_ERROR_CODES.FAILED_TO_GET_USER_INFO);
@@ -204,7 +208,7 @@ const linkSocialAccount = createAuthEndpoint("/link-social", {
204
208
  });
205
209
  }
206
210
  if (linkedAccount) throw APIError.from("CONFLICT", BASE_ERROR_CODES.SOCIAL_ACCOUNT_ALREADY_LINKED);
207
- if (!c.context.trustedProviders.includes(provider.id) && !linkingUserInfo.user.emailVerified || c.context.options.account?.accountLinking?.enabled === false) throw APIError.from("UNAUTHORIZED", {
211
+ if (!c.context.trustedProviders.includes(provider.id) && !toBoolean(linkingUserInfo.user.emailVerified) || c.context.options.account?.accountLinking?.enabled === false) throw APIError.from("UNAUTHORIZED", {
208
212
  message: "Account not linked - linking not allowed",
209
213
  code: "LINKING_NOT_ALLOWED"
210
214
  });
@@ -237,7 +241,11 @@ const linkSocialAccount = createAuthEndpoint("/link-social", {
237
241
  const state = await generateState(c, {
238
242
  link: {
239
243
  userId: session.user.id,
240
- email: session.user.email
244
+ email: session.user.email,
245
+ encryptedSessionToken: isStateful(c) ? await symmetricEncrypt({
246
+ key: derivePurposeKey(c.context.secretConfig, "oauth-link-session-token"),
247
+ data: session.session.token
248
+ }) : void 0
241
249
  },
242
250
  additionalData: c.body.additionalData,
243
251
  idTokenNonce
@@ -26,7 +26,7 @@ async function sendVerificationEmailFn(ctx, user) {
26
26
  ctx.context.logger.error("Verification email isn't enabled.");
27
27
  throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.VERIFICATION_EMAIL_NOT_ENABLED);
28
28
  }
29
- const token = await createEmailVerificationToken(ctx.context.secret, user.email, void 0, ctx.context.options.emailVerification?.expiresIn);
29
+ const token = await createEmailVerificationToken(ctx.context.secret, user.email, void 0, ctx.context.options.emailVerification?.expiresIn, { userId: user.id });
30
30
  const callbackURL = ctx.body.callbackURL ? encodeURIComponent(ctx.body.callbackURL) : encodeURIComponent("/");
31
31
  const url = `${ctx.context.baseURL}/verify-email?token=${token}&callbackURL=${callbackURL}`;
32
32
  await ctx.context.options.emailVerification.sendVerificationEmail({
@@ -183,24 +183,30 @@ const verifyEmail = createAuthEndpoint("/verify-email", {
183
183
  const parsed = z.object({
184
184
  email: z.email(),
185
185
  updateTo: z.string().optional(),
186
- requestType: z.string().optional()
186
+ requestType: z.string().optional(),
187
+ userId: z.string().optional()
187
188
  }).parse(jwt.payload);
188
- const user = await ctx.context.internalAdapter.findUserByEmail(parsed.email);
189
- if (!user) return redirectOnError(BASE_ERROR_CODES.USER_NOT_FOUND);
190
189
  if (parsed.updateTo) {
190
+ if (!parsed.userId) return redirectOnError(BASE_ERROR_CODES.INVALID_TOKEN);
191
+ const boundUser = await ctx.context.internalAdapter.findUserById(parsed.userId);
192
+ if (!boundUser) return redirectOnError(BASE_ERROR_CODES.USER_NOT_FOUND);
193
+ if (boundUser.email.toLowerCase() !== parsed.email) return redirectOnError(BASE_ERROR_CODES.INVALID_TOKEN);
191
194
  const session = await getSessionFromCtx(ctx);
192
- if (session && session.user.email !== parsed.email) return redirectOnError(BASE_ERROR_CODES.INVALID_USER);
195
+ if (session && session.user.id !== boundUser.id) return redirectOnError(BASE_ERROR_CODES.INVALID_USER);
193
196
  switch (parsed.requestType) {
194
197
  /**
195
198
  * User clicks confirmation -> sends verification to new email
196
199
  */
197
200
  case "change-email-confirmation": {
198
- const newToken = await createEmailVerificationToken(ctx.context.secret, parsed.email, parsed.updateTo, ctx.context.options.emailVerification?.expiresIn, { requestType: "change-email-verification" });
201
+ const newToken = await createEmailVerificationToken(ctx.context.secret, parsed.email, parsed.updateTo, ctx.context.options.emailVerification?.expiresIn, {
202
+ requestType: "change-email-verification",
203
+ userId: boundUser.id
204
+ });
199
205
  const updateCallbackURL = ctx.query.callbackURL ? encodeURIComponent(ctx.query.callbackURL) : encodeURIComponent("/");
200
206
  const url = `${ctx.context.baseURL}/verify-email?token=${newToken}&callbackURL=${updateCallbackURL}`;
201
207
  if (ctx.context.options.emailVerification?.sendVerificationEmail) await ctx.context.runInBackgroundOrAwait(ctx.context.options.emailVerification.sendVerificationEmail({
202
208
  user: {
203
- ...user.user,
209
+ ...boundUser,
204
210
  email: parsed.updateTo
205
211
  },
206
212
  url,
@@ -213,26 +219,18 @@ const verifyEmail = createAuthEndpoint("/verify-email", {
213
219
  * User clicks verification -> updates email
214
220
  */
215
221
  case "change-email-verification": {
216
- let activeSession = session;
217
- if (!activeSession) {
218
- const newSession = await ctx.context.internalAdapter.createSession(user.user.id);
219
- if (!newSession) throw APIError.from("INTERNAL_SERVER_ERROR", BASE_ERROR_CODES.FAILED_TO_CREATE_SESSION);
220
- activeSession = {
221
- session: newSession,
222
- user: user.user
223
- };
224
- }
225
- const updatedUser = await ctx.context.internalAdapter.updateUserByEmail(parsed.email, {
222
+ const emailVerified = boundUser.emailVerified;
223
+ const updatedUser = await ctx.context.internalAdapter.updateUser(boundUser.id, {
226
224
  email: parsed.updateTo,
227
- emailVerified: true
225
+ emailVerified
228
226
  });
229
- if (ctx.context.options.emailVerification?.afterEmailVerification) await ctx.context.options.emailVerification.afterEmailVerification(updatedUser, ctx.request);
230
- await setSessionCookie(ctx, {
231
- session: activeSession.session,
227
+ if (emailVerified && ctx.context.options.emailVerification?.afterEmailVerification) await ctx.context.options.emailVerification.afterEmailVerification(updatedUser, ctx.request);
228
+ if (session) await setSessionCookie(ctx, {
229
+ session: session.session,
232
230
  user: {
233
- ...activeSession.user,
231
+ ...session.user,
234
232
  email: parsed.updateTo,
235
- emailVerified: true
233
+ emailVerified
236
234
  }
237
235
  });
238
236
  if (ctx.query.callbackURL) throw ctx.redirect(ctx.query.callbackURL);
@@ -248,30 +246,21 @@ const verifyEmail = createAuthEndpoint("/verify-email", {
248
246
  * - updates email immediately
249
247
  */
250
248
  default: {
251
- let activeSession = session;
252
- if (!activeSession) {
253
- const newSession = await ctx.context.internalAdapter.createSession(user.user.id);
254
- if (!newSession) throw APIError.from("INTERNAL_SERVER_ERROR", BASE_ERROR_CODES.FAILED_TO_CREATE_SESSION);
255
- activeSession = {
256
- session: newSession,
257
- user: user.user
258
- };
259
- }
260
- const updatedUser = await ctx.context.internalAdapter.updateUserByEmail(parsed.email, {
249
+ const updatedUser = await ctx.context.internalAdapter.updateUser(boundUser.id, {
261
250
  email: parsed.updateTo,
262
251
  emailVerified: false
263
252
  });
264
- const newToken = await createEmailVerificationToken(ctx.context.secret, parsed.updateTo);
253
+ const newToken = await createEmailVerificationToken(ctx.context.secret, parsed.updateTo, void 0, void 0, { userId: boundUser.id });
265
254
  const updateCallbackURL = ctx.query.callbackURL ? encodeURIComponent(ctx.query.callbackURL) : encodeURIComponent("/");
266
255
  if (ctx.context.options.emailVerification?.sendVerificationEmail) await ctx.context.runInBackgroundOrAwait(ctx.context.options.emailVerification.sendVerificationEmail({
267
256
  user: updatedUser,
268
257
  url: `${ctx.context.baseURL}/verify-email?token=${newToken}&callbackURL=${updateCallbackURL}`,
269
258
  token: newToken
270
259
  }, safeCloneRequest(ctx.request)));
271
- await setSessionCookie(ctx, {
272
- session: activeSession.session,
260
+ if (session) await setSessionCookie(ctx, {
261
+ session: session.session,
273
262
  user: {
274
- ...activeSession.user,
263
+ ...session.user,
275
264
  email: parsed.updateTo,
276
265
  emailVerified: false
277
266
  }
@@ -284,6 +273,9 @@ const verifyEmail = createAuthEndpoint("/verify-email", {
284
273
  }
285
274
  }
286
275
  }
276
+ const user = await ctx.context.internalAdapter.findUserByEmail(parsed.email);
277
+ if (!user) return redirectOnError(BASE_ERROR_CODES.USER_NOT_FOUND);
278
+ if (parsed.userId && parsed.userId !== user.user.id) return redirectOnError(BASE_ERROR_CODES.INVALID_TOKEN);
287
279
  if (user.user.emailVerified) {
288
280
  if (ctx.query.callbackURL) throw ctx.redirect(ctx.query.callbackURL);
289
281
  return ctx.json({
@@ -167,7 +167,7 @@ const signInSocial = () => createAuthEndpoint("/sign-in/social", {
167
167
  refreshToken: c.body.idToken.refreshToken,
168
168
  user: c.body.idToken.user
169
169
  };
170
- const userInfo = await provider.getUserInfo(oauthTokens);
170
+ const userInfo = provider.getUserInfoFromIdToken ? await provider.getUserInfoFromIdToken(token) : await provider.getUserInfo(oauthTokens);
171
171
  if (!userInfo || !userInfo?.user) {
172
172
  c.context.logger.error("Failed to get user info", { provider: c.body.provider });
173
173
  throw APIError.from("UNAUTHORIZED", BASE_ERROR_CODES.FAILED_TO_GET_USER_INFO);
@@ -338,7 +338,7 @@ const signInEmail = () => createAuthEndpoint("/sign-in/email", {
338
338
  if (ctx.context.options?.emailAndPassword?.requireEmailVerification && !user.emailVerified) {
339
339
  if (!ctx.context.options?.emailVerification?.sendVerificationEmail) throw APIError.from("FORBIDDEN", BASE_ERROR_CODES.EMAIL_NOT_VERIFIED);
340
340
  if (ctx.context.options?.emailVerification?.sendOnSignIn) {
341
- const token = await createEmailVerificationToken(ctx.context.secret, user.email, void 0, ctx.context.options.emailVerification?.expiresIn);
341
+ const token = await createEmailVerificationToken(ctx.context.secret, user.email, void 0, ctx.context.options.emailVerification?.expiresIn, { userId: user.id });
342
342
  const callbackURL = ctx.body.callbackURL ? encodeURIComponent(ctx.body.callbackURL) : encodeURIComponent("/");
343
343
  const url = `${ctx.context.baseURL}/verify-email?token=${token}&callbackURL=${callbackURL}`;
344
344
  await ctx.context.runInBackgroundOrAwait(ctx.context.options.emailVerification.sendVerificationEmail({
@@ -251,7 +251,7 @@ const signUpEmail = () => createAuthEndpoint("/sign-up/email", {
251
251
  const callbackURL = body.callbackURL ? encodeURIComponent(body.callbackURL) : encodeURIComponent("/");
252
252
  const request = safeCloneRequest(ctx.request);
253
253
  await queueAfterTransactionHook(async () => {
254
- const token = await createEmailVerificationToken(ctx.context.secret, user.email, void 0, ctx.context.options.emailVerification?.expiresIn);
254
+ const token = await createEmailVerificationToken(ctx.context.secret, user.email, void 0, ctx.context.options.emailVerification?.expiresIn, { userId: user.id });
255
255
  const url = `${ctx.context.baseURL}/verify-email?token=${token}&callbackURL=${callbackURL}`;
256
256
  await ctx.context.runInBackgroundOrAwait(sendVerificationEmail({
257
257
  user,
@@ -450,7 +450,7 @@ const changeEmail = createAuthEndpoint("/change-email", {
450
450
  throw APIError.fromStatus("BAD_REQUEST", { message: "Verification email isn't enabled" });
451
451
  }
452
452
  if (await ctx.context.internalAdapter.findUserByEmail(newEmail)) {
453
- await createEmailVerificationToken(ctx.context.secret, ctx.context.session.user.email, newEmail, ctx.context.options.emailVerification?.expiresIn);
453
+ await createEmailVerificationToken(ctx.context.secret, ctx.context.session.user.email, newEmail, ctx.context.options.emailVerification?.expiresIn, { userId: ctx.context.session.user.id });
454
454
  ctx.context.logger.info("Change email attempt for existing email");
455
455
  return ctx.json({ status: true });
456
456
  }
@@ -467,7 +467,7 @@ const changeEmail = createAuthEndpoint("/change-email", {
467
467
  }
468
468
  });
469
469
  if (canSendVerification) {
470
- const token = await createEmailVerificationToken(ctx.context.secret, newEmail, void 0, ctx.context.options.emailVerification?.expiresIn);
470
+ const token = await createEmailVerificationToken(ctx.context.secret, newEmail, void 0, ctx.context.options.emailVerification?.expiresIn, { userId: ctx.context.session.user.id });
471
471
  const url = `${ctx.context.baseURL}/verify-email?token=${token}&callbackURL=${encodeURIComponent(ctx.body.callbackURL || "/")}`;
472
472
  await ctx.context.runInBackgroundOrAwait(canSendVerification({
473
473
  user: {
@@ -484,7 +484,10 @@ const changeEmail = createAuthEndpoint("/change-email", {
484
484
  * If the email is verified, we need to send a verification email
485
485
  */
486
486
  if (canSendConfirmation) {
487
- const token = await createEmailVerificationToken(ctx.context.secret, ctx.context.session.user.email, newEmail, ctx.context.options.emailVerification?.expiresIn, { requestType: "change-email-confirmation" });
487
+ const token = await createEmailVerificationToken(ctx.context.secret, ctx.context.session.user.email, newEmail, ctx.context.options.emailVerification?.expiresIn, {
488
+ requestType: "change-email-confirmation",
489
+ userId: ctx.context.session.user.id
490
+ });
488
491
  const url = `${ctx.context.baseURL}/verify-email?token=${token}&callbackURL=${encodeURIComponent(ctx.body.callbackURL || "/")}`;
489
492
  await ctx.context.runInBackgroundOrAwait(canSendConfirmation({
490
493
  user: ctx.context.session.user,
@@ -498,7 +501,10 @@ const changeEmail = createAuthEndpoint("/change-email", {
498
501
  ctx.context.logger.error("Verification email isn't enabled.");
499
502
  throw APIError.fromStatus("BAD_REQUEST", { message: "Verification email isn't enabled" });
500
503
  }
501
- const token = await createEmailVerificationToken(ctx.context.secret, ctx.context.session.user.email, newEmail, ctx.context.options.emailVerification?.expiresIn, { requestType: "change-email-verification" });
504
+ const token = await createEmailVerificationToken(ctx.context.secret, ctx.context.session.user.email, newEmail, ctx.context.options.emailVerification?.expiresIn, {
505
+ requestType: "change-email-verification",
506
+ userId: ctx.context.session.user.id
507
+ });
502
508
  const url = `${ctx.context.baseURL}/verify-email?token=${token}&callbackURL=${encodeURIComponent(ctx.body.callbackURL || "/")}`;
503
509
  await ctx.context.runInBackgroundOrAwait(canSendVerification({
504
510
  user: {
@@ -1,9 +1,24 @@
1
1
  import { wildcardMatch } from "../utils/wildcard.mjs";
2
2
  import { getHost, getOrigin, getProtocol } from "../utils/url.mjs";
3
+ import * as z from "zod";
3
4
  //#region src/auth/trusted-origins.ts
4
5
  const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f-\u009f]/;
5
6
  const RELATIVE_URL_PARSER_ORIGIN = "https://better-auth.invalid";
6
7
  const ENCODED_PATH_SEPARATOR_PATTERN = /%2[fF]|%5[cC]/;
8
+ const IP_WILDCARD_HOST_PATTERN = /^(?=.*\d)[\d*?.]+$/;
9
+ const ipv4Schema = z.ipv4();
10
+ /**
11
+ * A wildcard host made only of digits, dots and wildcards (`192.168.*.*`,
12
+ * `10.*`) names an address range, so it admits only an IPv4 address. A plain
13
+ * `*` would otherwise cross `.` and let `192.168.attacker.com` match. Other
14
+ * hosts keep the documented subdomain matching.
15
+ */
16
+ const isAllowedIPv4Host = (patternAuthority, authority) => {
17
+ const [patternHost = ""] = patternAuthority.split(":");
18
+ if (!IP_WILDCARD_HOST_PATTERN.test(patternHost)) return true;
19
+ const [host = ""] = authority.split(":");
20
+ return z.validate(ipv4Schema, host);
21
+ };
7
22
  /**
8
23
  * Resolves `.` and `..` segments in a path after percent-decoding so a
9
24
  * path-pinned pattern cannot be bypassed with traversal: e.g.
@@ -33,7 +48,7 @@ const normalizePath = (path) => {
33
48
  * host); the path is percent-decoded and resolved so traversal cannot bypass
34
49
  * a path-pinned pattern.
35
50
  */
36
- const parseCustomSchemeOrigin = (value) => {
51
+ const parseCustomSchemeOrigin = (value, wildcardPattern = false) => {
37
52
  if (CONTROL_CHARACTER_PATTERN.test(value)) return null;
38
53
  const schemeEnd = value.indexOf(":");
39
54
  if (schemeEnd <= 0) return null;
@@ -42,7 +57,7 @@ const parseCustomSchemeOrigin = (value) => {
42
57
  let authority = "";
43
58
  if (rest.startsWith("//")) {
44
59
  rest = rest.slice(2);
45
- const authorityEnd = rest.search(/[/?#]/);
60
+ const authorityEnd = rest.search(wildcardPattern ? /[/#]/ : /[/?#]/);
46
61
  if (authorityEnd === -1) {
47
62
  authority = rest;
48
63
  rest = "";
@@ -51,7 +66,7 @@ const parseCustomSchemeOrigin = (value) => {
51
66
  rest = rest.slice(authorityEnd);
52
67
  }
53
68
  }
54
- const pathEnd = rest.search(/[?#]/);
69
+ const pathEnd = rest.search(wildcardPattern ? /#/ : /[?#]/);
55
70
  const path = normalizePath(pathEnd === -1 ? rest : rest.slice(0, pathEnd));
56
71
  return {
57
72
  scheme,
@@ -87,17 +102,27 @@ const isSafeRelativeURL = (value) => {
87
102
  */
88
103
  const matchesOriginPattern = (url, pattern, settings) => {
89
104
  if (url.startsWith("/")) return settings?.allowRelativePaths === true && isSafeRelativeURL(url);
90
- if (pattern.includes("*") || pattern.includes("?")) {
91
- if (pattern.includes("://")) return wildcardMatch(pattern)(getOrigin(url) || url);
105
+ const hasWildcard = pattern.includes("*") || pattern.includes("?");
106
+ if (hasWildcard && !pattern.includes("://")) {
92
107
  const host = getHost(url);
93
108
  if (!host) return false;
94
- return wildcardMatch(pattern)(host);
109
+ return wildcardMatch(pattern)(host) && isAllowedIPv4Host(pattern, host);
95
110
  }
96
111
  const protocol = getProtocol(url);
97
- if (protocol === "http:" || protocol === "https:" || !protocol) return pattern === getOrigin(url);
112
+ if (protocol === "http:" || protocol === "https:" || !protocol) {
113
+ if (!hasWildcard) return pattern === getOrigin(url);
114
+ const origin = getOrigin(url);
115
+ if (origin === null || !wildcardMatch(pattern)(origin)) return false;
116
+ return isAllowedIPv4Host(pattern.slice(pattern.indexOf("://") + 3), origin.slice(origin.indexOf("://") + 3));
117
+ }
98
118
  const parsed = parseCustomSchemeOrigin(url);
99
- const parsedPattern = parseCustomSchemeOrigin(pattern);
119
+ const parsedPattern = parseCustomSchemeOrigin(pattern, hasWildcard);
100
120
  if (!parsed || !parsedPattern || parsed.scheme !== parsedPattern.scheme) return false;
121
+ if (hasWildcard) {
122
+ if (parsed.authority.includes("@")) return false;
123
+ if (parsedPattern.authority && !(wildcardMatch(parsedPattern.authority)(parsed.authority) && isAllowedIPv4Host(parsedPattern.authority, parsed.authority))) return false;
124
+ return !parsedPattern.path || wildcardMatch(parsedPattern.path)(parsed.path || "/");
125
+ }
101
126
  if (parsedPattern.authority && parsed.authority !== parsedPattern.authority) return false;
102
127
  if (!parsedPattern.path) return true;
103
128
  return parsed.path === parsedPattern.path || parsed.path.startsWith(`${parsedPattern.path}/`);
@@ -16,7 +16,8 @@ import { OrganizationOptions } from "../plugins/organization/types.mjs";
16
16
  import { InferInvitation, InferMember, InferOrganization, InferOrganizationRolesFromOption, InferOrganizationZodRolesFromOption, InferTeam, Invitation, InvitationInput, InvitationStatus, Member, MemberInput, Organization, OrganizationInput, OrganizationRole, OrganizationSchema, Team, TeamInput, TeamMember, TeamMemberInput, defaultRolesSchema, invitationSchema, invitationStatus, memberSchema, organizationRoleSchema, organizationSchema, roleSchema, teamMemberSchema, teamSchema } from "../plugins/organization/schema.mjs";
17
17
  import { getOrgAdapter } from "../plugins/organization/adapter.mjs";
18
18
  import { hasPermission } from "../plugins/organization/has-permission.mjs";
19
- import { DefaultOrganizationPlugin, DynamicAccessControlEndpoints, OrganizationCreator, OrganizationEndpoints, OrganizationPlugin, TeamEndpoints, organization, parseRoles } from "../plugins/organization/organization.mjs";
19
+ import { parseRoles } from "../plugins/organization/roles.mjs";
20
+ import { DefaultOrganizationPlugin, DynamicAccessControlEndpoints, OrganizationCreator, OrganizationEndpoints, OrganizationPlugin, TeamEndpoints, organization } from "../plugins/organization/organization.mjs";
20
21
  import "../plugins/organization/index.mjs";
21
22
  import { BetterAuthOptions, BetterAuthPlugin } from "@better-auth/core";
22
23
  import { DBPrimitive } from "@better-auth/core/db";
@@ -177,6 +177,7 @@ async function setSessionCookie(ctx, session, dontRememberMe, overrides) {
177
177
  ...overrides
178
178
  });
179
179
  if (dontRememberMe) await ctx.setSignedCookie(ctx.context.authCookies.dontRememberToken.name, "true", ctx.context.secret, ctx.context.authCookies.dontRememberToken.attributes);
180
+ else if (dontRememberMeCookie) expireCookie(ctx, ctx.context.authCookies.dontRememberToken);
180
181
  await setCookieCache(ctx, session, dontRememberMe);
181
182
  ctx.context.setNewSession(session);
182
183
  }
@@ -38,7 +38,7 @@ const createInternalAdapter = (adapter, ctx) => {
38
38
  * or `deleteUserSessions` sweeping a user's accumulated preserved rows) would
39
39
  * re-match it and re-fire the hooks, re-dispatching back-channel logout.
40
40
  */
41
- const endPreservedSessions = (where) => {
41
+ const endPreservedSessions = (where, afterEnd) => {
42
42
  const liveSessions = [...where, {
43
43
  field: "expiresAt",
44
44
  value: /* @__PURE__ */ new Date(),
@@ -51,6 +51,7 @@ const createInternalAdapter = (adapter, ctx) => {
51
51
  where: liveSessions,
52
52
  update: { expiresAt: /* @__PURE__ */ new Date() }
53
53
  });
54
+ await afterEnd?.();
54
55
  },
55
56
  executeMainFn: false
56
57
  });
@@ -78,10 +79,20 @@ const createInternalAdapter = (adapter, ctx) => {
78
79
  const activeSessions = await secondaryStorage.get(`active-sessions-${userId}`);
79
80
  return activeSessions ? safeJSONParse(activeSessions) || [] : [];
80
81
  }
81
- async function deleteCachedUserSessions(userId, sessionReferences) {
82
+ const revokedBeforeKey = (userId) => `revoked-sessions-before-${userId}`;
83
+ const revokedBeforeTTL = Math.max(sessionExpiration, 86400, Number(options.plugins?.find((plugin) => plugin.id === "admin")?.options?.impersonationSessionDuration) || 0);
84
+ async function isRevokedSession(session, revokedBeforeByUser) {
85
+ if (!secondaryStorage) return false;
86
+ const revokedBefore = revokedBeforeByUser?.get(session.userId) ?? Number(await secondaryStorage.get(revokedBeforeKey(session.userId)));
87
+ revokedBeforeByUser?.set(session.userId, revokedBefore);
88
+ return new Date(session.createdAt).getTime() < revokedBefore;
89
+ }
90
+ async function deleteCachedUserSessions(userId, sessionReferences, revokedAt) {
82
91
  if (!secondaryStorage) return;
92
+ const previousRevokedBefore = Number(await secondaryStorage.get(revokedBeforeKey(userId)));
93
+ await secondaryStorage.set(revokedBeforeKey(userId), String(Math.max(revokedAt, previousRevokedBefore || 0)), revokedBeforeTTL);
83
94
  const deletedTokens = new Set(sessionReferences.map((session) => session.token));
84
- for (const { token } of sessionReferences) await secondaryStorage.delete(token);
95
+ for (const token of deletedTokens) await secondaryStorage.delete(token);
85
96
  const activeSessionsKey = `active-sessions-${userId}`;
86
97
  const currentSessionReferences = await getActiveSessionReferences(userId);
87
98
  const now = Date.now();
@@ -97,7 +108,8 @@ const createInternalAdapter = (adapter, ctx) => {
97
108
  async function queueCachedUserSessionDeletion(userId, sessionReferences) {
98
109
  if (!secondaryStorage) return;
99
110
  const references = sessionReferences ?? await getActiveSessionReferences(userId);
100
- await queueAfterTransactionHook(() => deleteCachedUserSessions(userId, references), { onError(error) {
111
+ const revokedAt = Date.now();
112
+ await queueAfterTransactionHook(() => deleteCachedUserSessions(userId, references, revokedAt), { onError(error) {
101
113
  logger.error("Failed to delete committed user sessions from secondary storage", error);
102
114
  } });
103
115
  }
@@ -194,6 +206,7 @@ const createInternalAdapter = (adapter, ctx) => {
194
206
  const now = Date.now();
195
207
  const seenTokens = /* @__PURE__ */ new Set();
196
208
  const sessions = [];
209
+ const revokedBeforeByUser = /* @__PURE__ */ new Map();
197
210
  for (const { token, expiresAt } of list) {
198
211
  if (expiresAt <= now || seenTokens.has(token)) continue;
199
212
  seenTokens.add(token);
@@ -201,7 +214,7 @@ const createInternalAdapter = (adapter, ctx) => {
201
214
  if (!data) continue;
202
215
  try {
203
216
  const parsed = typeof data === "string" ? JSON.parse(data) : data;
204
- if (!parsed?.session) continue;
217
+ if (!parsed?.session || await isRevokedSession(parsed.session, revokedBeforeByUser)) continue;
205
218
  sessions.push(parseSessionOutput(ctx.options, {
206
219
  ...parsed.session,
207
220
  expiresAt: new Date(parsed.session.expiresAt)
@@ -312,7 +325,10 @@ const createInternalAdapter = (adapter, ctx) => {
312
325
  });
313
326
  const sessionTTL = getTTLSeconds(data.expiresAt, now);
314
327
  if (sessionTTL > 0) await secondaryStorage.set(data.token, JSON.stringify({
315
- session: sessionData,
328
+ session: {
329
+ ...sessionData,
330
+ createdAt: data.createdAt
331
+ },
316
332
  user
317
333
  }), sessionTTL);
318
334
  return sessionData;
@@ -336,7 +352,7 @@ const createInternalAdapter = (adapter, ctx) => {
336
352
  if (!sessionStringified && (!options.session?.storeSessionInDatabase || ctx.options.session?.preserveSessionInDatabase)) return null;
337
353
  if (sessionStringified) {
338
354
  const s = safeJSONParse(sessionStringified);
339
- if (!s) return null;
355
+ if (!s || await isRevokedSession(s.session)) return null;
340
356
  return {
341
357
  session: parseSessionOutput(ctx.options, {
342
358
  ...s.session,
@@ -372,11 +388,12 @@ const createInternalAdapter = (adapter, ctx) => {
372
388
  const uniqueTokens = [...new Set(sessionTokens)];
373
389
  const sessionsByToken = /* @__PURE__ */ new Map();
374
390
  const missingTokens = [];
391
+ const revokedBeforeByUser = /* @__PURE__ */ new Map();
375
392
  if (secondaryStorage) for (const sessionToken of uniqueTokens) {
376
393
  const sessionStringified = await secondaryStorage.get(sessionToken);
377
394
  if (sessionStringified) try {
378
395
  const s = typeof sessionStringified === "string" ? JSON.parse(sessionStringified) : sessionStringified;
379
- if (!s) continue;
396
+ if (!s || await isRevokedSession(s.session, revokedBeforeByUser)) continue;
380
397
  const expiresAt = new Date(s.session.expiresAt);
381
398
  if (options?.onlyActiveSessions && expiresAt <= /* @__PURE__ */ new Date()) continue;
382
399
  const session = {
@@ -517,23 +534,39 @@ const createInternalAdapter = (adapter, ctx) => {
517
534
  },
518
535
  deleteUserSessions: async (userId) => {
519
536
  const sessionReferences = await getActiveSessionReferences(userId);
520
- if (secondaryStorage) {
521
- if (!options.session?.storeSessionInDatabase) {
522
- await queueCachedUserSessionDeletion(userId, sessionReferences);
523
- return;
524
- }
525
- if (ctx.options.session?.preserveSessionInDatabase) {
526
- if (await endPreservedSessions([{
527
- field: "userId",
528
- value: userId
529
- }]) !== null) await queueCachedUserSessionDeletion(userId, sessionReferences);
530
- return;
531
- }
537
+ if (!secondaryStorage) {
538
+ await deleteManyWithHooks([{
539
+ field: "userId",
540
+ value: userId
541
+ }], "session", void 0);
542
+ return;
543
+ }
544
+ if (!options.session?.storeSessionInDatabase) {
545
+ await queueCachedUserSessionDeletion(userId, sessionReferences);
546
+ return;
547
+ }
548
+ const revokeCachedSessions = async () => queueCachedUserSessionDeletion(userId, [...sessionReferences, ...await getActiveSessionReferences(userId)]);
549
+ if (ctx.options.session?.preserveSessionInDatabase) {
550
+ await endPreservedSessions([{
551
+ field: "userId",
552
+ value: userId
553
+ }], revokeCachedSessions);
554
+ return;
532
555
  }
533
- if (await deleteManyWithHooks([{
556
+ await deleteManyWithHooks([{
534
557
  field: "userId",
535
558
  value: userId
536
- }], "session", void 0) !== null) await queueCachedUserSessionDeletion(userId, sessionReferences);
559
+ }], "session", {
560
+ fn: async (where) => {
561
+ const deleted = await (await getCurrentAdapter(adapter)).deleteMany({
562
+ model: "session",
563
+ where
564
+ });
565
+ await revokeCachedSessions();
566
+ return deleted;
567
+ },
568
+ executeMainFn: false
569
+ });
537
570
  },
538
571
  deleteSessions: async (sessionTokens) => {
539
572
  if (secondaryStorage) {
@@ -590,6 +623,8 @@ const createInternalAdapter = (adapter, ctx) => {
590
623
  join: { ...options?.includeAccounts ? { account: true } : {} }
591
624
  });
592
625
  if (!result) return null;
626
+ const normalize = (value) => value.normalize("NFC").toLowerCase();
627
+ if (normalize(result.email) !== normalize(email)) return null;
593
628
  const { account: accounts, ...user } = result;
594
629
  return {
595
630
  user,