better-auth 1.7.1 → 1.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,4 @@
1
+ import { getBaseURL, getOrigin } from "../../utils/url.mjs";
1
2
  import { matchesOriginPattern } from "../../auth/trusted-origins.mjs";
2
3
  import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
3
4
  import { normalizePathname } from "@better-auth/core/utils/url";
@@ -95,7 +96,8 @@ const originCheck = (getValue) => createAuthMiddleware(async (ctx) => {
95
96
  async function validateOrigin(ctx, forceValidate = false) {
96
97
  const headers = ctx.request?.headers;
97
98
  if (!headers || !ctx.request) return;
98
- const originHeader = headers.get("origin") || headers.get("referer") || "";
99
+ const origin = headers.get("origin");
100
+ const originHeader = origin || headers.get("referer") || "";
99
101
  const useCookies = headers.has("cookie");
100
102
  if (ctx.context.skipCSRFCheck) return;
101
103
  if (shouldSkipCSRFForBackwardCompat(ctx)) {
@@ -104,11 +106,13 @@ async function validateOrigin(ctx, forceValidate = false) {
104
106
  }
105
107
  if (shouldSkipOriginCheck(ctx)) return;
106
108
  if (!(forceValidate || useCookies)) return;
107
- if (!originHeader || originHeader === "null") throw APIError.from("FORBIDDEN", BASE_ERROR_CODES.MISSING_OR_NULL_ORIGIN);
109
+ const inferredBaseURL = origin === "null" && headers.get("sec-fetch-site") === "same-origin" ? getBaseURL(void 0, ctx.context.options.basePath, ctx.request, false, ctx.context.options.advanced?.trustedProxyHeaders) : void 0;
110
+ const originToValidate = (inferredBaseURL ? getOrigin(inferredBaseURL) : void 0) ?? originHeader;
111
+ if (!originToValidate || originToValidate === "null") throw APIError.from("FORBIDDEN", BASE_ERROR_CODES.MISSING_OR_NULL_ORIGIN);
108
112
  const trustedOrigins = Array.isArray(ctx.context.options.trustedOrigins) ? ctx.context.trustedOrigins : [...ctx.context.trustedOrigins, ...(await ctx.context.options.trustedOrigins?.(ctx.request))?.filter((v) => Boolean(v)) || []];
109
- if (!trustedOrigins.some((origin) => matchesOriginPattern(originHeader, origin))) {
110
- ctx.context.logger.error(`Invalid origin: ${originHeader}`);
111
- ctx.context.logger.info(`If it's a valid URL, please add ${originHeader} to trustedOrigins in your auth config\n`, `Current list of trustedOrigins: ${trustedOrigins}`);
113
+ if (!trustedOrigins.some((origin) => matchesOriginPattern(originToValidate, origin))) {
114
+ ctx.context.logger.error(`Invalid origin: ${originToValidate}`);
115
+ ctx.context.logger.info(`If it's a valid URL, please add ${originToValidate} to trustedOrigins in your auth config\n`, `Current list of trustedOrigins: ${trustedOrigins}`);
112
116
  throw APIError.from("FORBIDDEN", BASE_ERROR_CODES.INVALID_ORIGIN);
113
117
  }
114
118
  }
@@ -10,6 +10,7 @@ import { generateIdTokenNonce, generateState, parseState } from "../../oauth2/st
10
10
  import { HIDE_METADATA } from "../../utils/hide-metadata.mjs";
11
11
  import { mergeScopes } from "@better-auth/core/oauth2";
12
12
  import { safeJSONParse } from "@better-auth/core/utils/json";
13
+ import { appendQueryParams } from "@better-auth/core/utils/url";
13
14
  import { createAuthEndpoint } from "@better-auth/core/api";
14
15
  import * as z from "zod";
15
16
  //#region src/api/routes/callback.ts
@@ -52,7 +53,8 @@ const callbackOAuth = createAuthEndpoint("/callback/:id", {
52
53
  else throw new Error("Unsupported method");
53
54
  } catch (e) {
54
55
  c.context.logger.error("INVALID_CALLBACK_REQUEST", e);
55
- throw c.redirect(`${defaultErrorURL}?error=invalid_callback_request`);
56
+ const redirectURL = appendQueryParams(defaultErrorURL, new URLSearchParams({ error: "invalid_callback_request" }));
57
+ throw c.redirect(redirectURL);
56
58
  }
57
59
  const { code, error, state, error_description, device_id, user: userData, iss } = queryOrBody;
58
60
  if (state === void 0 && code) {
@@ -71,16 +73,16 @@ const callbackOAuth = createAuthEndpoint("/callback/:id", {
71
73
  }
72
74
  if (!state) {
73
75
  c.context.logger.error("State not found", error);
74
- const url = `${defaultErrorURL}${defaultErrorURL.includes("?") ? "&" : "?"}error=state_not_found`;
75
- throw c.redirect(url);
76
+ const redirectURL = appendQueryParams(defaultErrorURL, new URLSearchParams({ error: "state_not_found" }));
77
+ throw c.redirect(redirectURL);
76
78
  }
77
79
  const { codeVerifier, callbackURL, link, errorURL, newUserURL, requestSignUp, idTokenNonce } = await parseState(c);
78
80
  function redirectOnError(error, description) {
79
81
  const baseURL = errorURL ?? defaultErrorURL;
80
82
  const params = new URLSearchParams({ error });
81
83
  if (description) params.set("error_description", description);
82
- const url = `${baseURL}${baseURL.includes("?") ? "&" : "?"}${params.toString()}`;
83
- throw c.redirect(url);
84
+ const redirectURL = appendQueryParams(baseURL, params);
85
+ throw c.redirect(redirectURL);
84
86
  }
85
87
  if (error) redirectOnError(error, error_description);
86
88
  if (!code) {
@@ -5,6 +5,7 @@ import { setSessionCookie } from "../../cookies/index.mjs";
5
5
  import { getSessionFromCtx } from "./session.mjs";
6
6
  import { safeCloneRequest } from "../../utils/request.mjs";
7
7
  import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
8
+ import { appendQueryParams } from "@better-auth/core/utils/url";
8
9
  import { createAuthEndpoint } from "@better-auth/core/api";
9
10
  import * as z from "zod";
10
11
  import { jwtVerify } from "jose";
@@ -165,8 +166,9 @@ const verifyEmail = createAuthEndpoint("/verify-email", {
165
166
  }, async (ctx) => {
166
167
  function redirectOnError(error) {
167
168
  if (ctx.query.callbackURL) {
168
- if (ctx.query.callbackURL.includes("?")) throw ctx.redirect(`${ctx.query.callbackURL}&error=${error.code}`);
169
- throw ctx.redirect(`${ctx.query.callbackURL}?error=${error.code}`);
169
+ const params = new URLSearchParams({ error: error.code });
170
+ const redirectURL = appendQueryParams(ctx.query.callbackURL, params);
171
+ throw ctx.redirect(redirectURL);
170
172
  }
171
173
  throw APIError.from("UNAUTHORIZED", error);
172
174
  }
@@ -1,5 +1,6 @@
1
1
  import { HIDE_METADATA } from "../../utils/hide-metadata.mjs";
2
2
  import { isProduction } from "@better-auth/core/env";
3
+ import { appendQueryParams } from "@better-auth/core/utils/url";
3
4
  import { createAuthEndpoint } from "@better-auth/core/api";
4
5
  //#region src/api/routes/error.ts
5
6
  function sanitize(input) {
@@ -364,18 +365,21 @@ const error = createAuthEndpoint("/error", {
364
365
  const unsanitizedDescription = url.searchParams.get("error_description") || null;
365
366
  const safeCode = /^[\'A-Za-z0-9_-]+$/.test(unsanitizedCode || "") ? unsanitizedCode : "UNKNOWN";
366
367
  const safeDescription = unsanitizedDescription ? sanitize(unsanitizedDescription) : null;
367
- const queryParams = new URLSearchParams();
368
- queryParams.set("error", safeCode);
369
- if (unsanitizedDescription) queryParams.set("error_description", unsanitizedDescription);
368
+ const params = new URLSearchParams();
369
+ params.set("error", safeCode);
370
+ if (unsanitizedDescription) params.set("error_description", unsanitizedDescription);
370
371
  const options = c.context.options;
371
372
  const errorURL = options.onAPIError?.errorURL;
372
- if (errorURL) return new Response(null, {
373
- status: 302,
374
- headers: { Location: `${errorURL}${errorURL.includes("?") ? "&" : "?"}${queryParams.toString()}` }
375
- });
373
+ if (errorURL) {
374
+ const redirectURL = appendQueryParams(errorURL, params);
375
+ return new Response(null, {
376
+ status: 302,
377
+ headers: { Location: redirectURL }
378
+ });
379
+ }
376
380
  if (isProduction && !options.onAPIError?.customizeDefaultErrorPage) return new Response(null, {
377
381
  status: 302,
378
- headers: { Location: `/?${queryParams.toString()}` }
382
+ headers: { Location: `/?${params.toString()}` }
379
383
  });
380
384
  return new Response(html(c.context.options, safeCode, safeDescription), { headers: { "Content-Type": "text/html" } });
381
385
  });
@@ -9,6 +9,7 @@ import { handleOAuthUserInfo } from "../../oauth2/link-account.mjs";
9
9
  import { generateIdTokenNonce, generateState } from "../../oauth2/state.mjs";
10
10
  import { safeCloneRequest } from "../../utils/request.mjs";
11
11
  import { createEmailVerificationToken } from "./email-verification.mjs";
12
+ import "../../utils/index.mjs";
12
13
  import { createLocalAccountIssuer } from "@better-auth/core/db";
13
14
  import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
14
15
  import { additionalAuthorizationParamsSchema, supportsIdTokenSignIn, verifyProviderIdToken } from "@better-auth/core/oauth2";
@@ -54,6 +54,26 @@ const parseCustomSchemeOrigin = (value) => {
54
54
  path
55
55
  };
56
56
  };
57
+ const RELATIVE_URL_PARSER_ORIGIN = "https://better-auth.invalid";
58
+ const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f-\u009f]/;
59
+ const ENCODED_PATH_SEPARATOR_PATTERN = /%2[fF]|%5[cC]/;
60
+ /**
61
+ * Validates root-relative redirects against ambiguous browser and router parsing.
62
+ *
63
+ * @see https://www.rfc-editor.org/rfc/rfc3986.html#section-4.2
64
+ * @see https://url.spec.whatwg.org/#concept-basic-url-parser
65
+ */
66
+ const isSafeRelativeURL = (value) => {
67
+ if (!value.startsWith("/") || value.startsWith("//") || value.includes("\\") || CONTROL_CHARACTER_PATTERN.test(value)) return false;
68
+ const pathEnd = value.search(/[?#]/);
69
+ const path = pathEnd === -1 ? value : value.slice(0, pathEnd);
70
+ if (ENCODED_PATH_SEPARATOR_PATTERN.test(path)) return false;
71
+ try {
72
+ return new URL(value, RELATIVE_URL_PARSER_ORIGIN).origin === RELATIVE_URL_PARSER_ORIGIN;
73
+ } catch {
74
+ return false;
75
+ }
76
+ };
57
77
  /**
58
78
  * Matches the given url against an origin or origin pattern
59
79
  * See "options.trustedOrigins" for details of supported patterns
@@ -64,10 +84,7 @@ const parseCustomSchemeOrigin = (value) => {
64
84
  * @returns {boolean} true if the URL matches the origin pattern, false otherwise.
65
85
  */
66
86
  const matchesOriginPattern = (url, pattern, settings) => {
67
- if (url.startsWith("/")) {
68
- if (settings?.allowRelativePaths) return url.startsWith("/") && /^\/(?!\/|\\|%2f|%5c)[\w\-.\+/@]*(?:\?[\w\-.\+/=&%@]*)?$/.test(url);
69
- return false;
70
- }
87
+ if (url.startsWith("/")) return settings?.allowRelativePaths === true && isSafeRelativeURL(url);
71
88
  if (pattern.includes("*") || pattern.includes("?")) {
72
89
  if (pattern.includes("://")) return wildcardMatch(pattern)(getOrigin(url) || url);
73
90
  const host = getHost(url);
@@ -22,7 +22,7 @@ type ClientSession<Option extends BetterAuthClientOptions> = InferClientAPI<Opti
22
22
  * Lynx client returned by `createAuthClient`.
23
23
  */
24
24
  type LynxAuthClient<Option extends BetterAuthClientOptions> = UnionToIntersection<InferResolvedHooks<Option>> & InferClientAPI<Option> & InferActions<Option> & {
25
- hydrateSession: (session: NonNullable<ClientSession<Option>> | null) => void;
25
+ hydrateSession(session: NonNullable<ClientSession<Option>> | null): void;
26
26
  useSession: () => {
27
27
  data: ClientSession<Option>;
28
28
  isPending: boolean;
@@ -22,7 +22,7 @@ type ClientSession<Option extends BetterAuthClientOptions> = InferClientAPI<Opti
22
22
  * React client returned by `createAuthClient`.
23
23
  */
24
24
  type ReactAuthClient<Option extends BetterAuthClientOptions> = UnionToIntersection<InferResolvedHooks<Option>> & InferClientAPI<Option> & InferActions<Option> & {
25
- hydrateSession: (session: NonNullable<ClientSession<Option>> | null) => void;
25
+ hydrateSession(session: NonNullable<ClientSession<Option>> | null): void;
26
26
  useSession: () => {
27
27
  data: ClientSession<Option>;
28
28
  isPending: boolean;
@@ -22,7 +22,7 @@ type ClientSession<Option extends BetterAuthClientOptions> = InferClientAPI<Opti
22
22
  * Solid client returned by `createAuthClient`.
23
23
  */
24
24
  type SolidAuthClient<Option extends BetterAuthClientOptions> = UnionToIntersection<InferResolvedHooks<Option>> & InferClientAPI<Option> & InferActions<Option> & {
25
- hydrateSession: (session: NonNullable<ClientSession<Option>> | null) => void;
25
+ hydrateSession(session: NonNullable<ClientSession<Option>> | null): void;
26
26
  useSession: () => Accessor<{
27
27
  data: ClientSession<Option>;
28
28
  isPending: boolean;
@@ -22,7 +22,7 @@ type ClientSession<Option extends BetterAuthClientOptions> = InferClientAPI<Opti
22
22
  * Svelte client returned by `createAuthClient`.
23
23
  */
24
24
  type SvelteAuthClient<Option extends BetterAuthClientOptions> = UnionToIntersection<InferResolvedHooks<Option>> & InferClientAPI<Option> & InferActions<Option> & {
25
- hydrateSession: (session: NonNullable<ClientSession<Option>> | null) => void;
25
+ hydrateSession(session: NonNullable<ClientSession<Option>> | null): void;
26
26
  useSession: () => Atom<{
27
27
  data: ClientSession<Option>;
28
28
  error: BetterFetchError | null;
@@ -20,7 +20,7 @@ type ClientSession<Option extends BetterAuthClientOptions> = InferClientAPI<Opti
20
20
  * Client returned by `createAuthClient`.
21
21
  */
22
22
  type AuthClient<Option extends BetterAuthClientOptions> = UnionToIntersection<InferResolvedHooks<Option>> & InferClientAPI<Option> & InferActions<Option> & {
23
- hydrateSession: (session: NonNullable<ClientSession<Option>> | null) => void;
23
+ hydrateSession(session: NonNullable<ClientSession<Option>> | null): void;
24
24
  useSession: Atom<{
25
25
  data: ClientSession<Option>;
26
26
  error: BetterFetchError | null;
@@ -42,7 +42,7 @@ type VueUseSession<Option extends BetterAuthClientOptions> = {
42
42
  * Vue client returned by `createAuthClient`.
43
43
  */
44
44
  type VueAuthClient<Option extends BetterAuthClientOptions> = UnionToIntersection<InferResolvedHooks<Option>> & InferClientAPI<Option> & InferActions<Option> & {
45
- hydrateSession: (session: NonNullable<ClientSession<Option>> | null) => void;
45
+ hydrateSession(session: NonNullable<ClientSession<Option>> | null): void;
46
46
  useSession: VueUseSession<Option>;
47
47
  $Infer: {
48
48
  Session: NonNullable<ClientSession<Option>>;
@@ -1,4 +1,5 @@
1
1
  import { sessionSchema, userSchema } from "@better-auth/core/db";
2
+ import { logger } from "@better-auth/core/env";
2
3
  import { safeJSONParse } from "@better-auth/core/utils/json";
3
4
  import * as z from "zod";
4
5
  //#region src/cookies/cache.ts
@@ -14,8 +15,15 @@ const compactCookieCacheSchema = z.object({
14
15
  signature: z.string()
15
16
  });
16
17
  function parseCookieCachePayload(value) {
17
- const result = cookieCachePayloadSchema.safeParse(safeJSONParse(value));
18
- return result.success ? result.data : null;
18
+ const parsed = safeJSONParse(value);
19
+ if (parsed === null) return null;
20
+ const result = cookieCachePayloadSchema.safeParse(parsed);
21
+ if (result.success) return result.data;
22
+ logger.warn("Cookie cache payload failed schema validation", { issues: result.error.issues.map(({ code, path }) => ({
23
+ code,
24
+ path
25
+ })) });
26
+ return null;
19
27
  }
20
28
  function parseCompactCookieCache(value) {
21
29
  const result = compactCookieCacheSchema.safeParse(value);
@@ -103,7 +103,20 @@ function databaseValueIsTrue(value) {
103
103
  if (typeof value === "number") return value !== 0;
104
104
  return value === "1" || value?.toLowerCase() === "true" || value === "t";
105
105
  }
106
- async function getDatabaseIndexes(db, dbType, schemaName) {
106
+ function toDatabaseIndexMap(indexes) {
107
+ return new Map(indexes.map((index) => {
108
+ const columns = [...index.columns].sort((left, right) => left.position - right.position);
109
+ return [createDatabaseIndexKey(index.table, index.name), {
110
+ columns: columns.flatMap((column) => column.name === null ? [] : [column.name]),
111
+ name: index.name,
112
+ table: index.table,
113
+ unique: index.unique,
114
+ validFullColumns: index.valid && !index.partial && columns.length > 0 && columns.every((column) => column.name !== null && column.fullLength)
115
+ }];
116
+ }));
117
+ }
118
+ async function getDatabaseIndexMap(db, dbType, schemaName, tableNames, introspectIndexes) {
119
+ if (introspectIndexes) return toDatabaseIndexMap(await introspectIndexes(tableNames));
107
120
  let rows;
108
121
  if (dbType === "sqlite") rows = (await sql`
109
122
  SELECT
@@ -151,7 +164,8 @@ async function getDatabaseIndexes(db, dbType, schemaName) {
151
164
  column_name AS columnName,
152
165
  non_unique AS nonUnique,
153
166
  seq_in_index AS columnPosition,
154
- sub_part AS prefixLength
167
+ sub_part AS prefixLength,
168
+ COALESCE(LOWER(comment) = 'disabled', FALSE) AS isDisabled
155
169
  FROM information_schema.statistics
156
170
  WHERE table_schema = DATABASE()
157
171
  `.execute(db)).rows;
@@ -180,7 +194,7 @@ async function getDatabaseIndexes(db, dbType, schemaName) {
180
194
  AND indexes.name IS NOT NULL
181
195
  AND index_columns.key_ordinal > 0
182
196
  `.execute(db)).rows;
183
- const indexRows = /* @__PURE__ */ new Map();
197
+ const indexMetadata = /* @__PURE__ */ new Map();
184
198
  for (const row of rows) {
185
199
  const table = row.tableName ?? row.table_name ?? row.TABLE_NAME ?? row.tablename ?? row.tbl_name;
186
200
  const name = row.indexName ?? row.index_name ?? row.INDEX_NAME ?? row.name;
@@ -190,28 +204,29 @@ async function getDatabaseIndexes(db, dbType, schemaName) {
190
204
  const nonUnique = row.nonUnique ?? row.non_unique ?? row.NON_UNIQUE;
191
205
  const unique = nonUnique === void 0 ? databaseValueIsTrue(row.isUnique ?? row.is_unique) : !databaseValueIsTrue(nonUnique);
192
206
  const position = Number(row.columnPosition ?? row.column_position ?? row.keyOrdinal ?? row.key_ordinal ?? row.ordinality ?? row.seqInIndex ?? row.seq_in_index ?? row.SEQ_IN_INDEX ?? row.seqno ?? 0);
193
- const index = indexRows.get(key) ?? {
194
- columns: [],
207
+ const indexColumn = {
208
+ fullLength: column !== void 0 && column !== null && (row.prefixLength === void 0 || row.prefixLength === null),
209
+ name: column ?? null,
210
+ position
211
+ };
212
+ const partial = databaseValueIsTrue(row.isPartial);
213
+ const valid = !databaseValueIsTrue(row.isDisabled) && !databaseValueIsTrue(row.isHypothetical) && (row.isValid === void 0 || databaseValueIsTrue(row.isValid));
214
+ const existing = indexMetadata.get(key);
215
+ indexMetadata.set(key, existing ? {
216
+ ...existing,
217
+ columns: [...existing.columns, indexColumn],
218
+ partial: existing.partial || partial,
219
+ valid: existing.valid && valid
220
+ } : {
221
+ columns: [indexColumn],
195
222
  name,
223
+ partial,
196
224
  table,
197
225
  unique,
198
- validFullColumns: true
199
- };
200
- if (column) index.columns.push({
201
- name: column,
202
- position
226
+ valid
203
227
  });
204
- else index.validFullColumns = false;
205
- if (databaseValueIsTrue(row.isPartial) || databaseValueIsTrue(row.isDisabled) || databaseValueIsTrue(row.isHypothetical) || row.isValid !== void 0 && !databaseValueIsTrue(row.isValid) || row.prefixLength !== void 0 && row.prefixLength !== null) index.validFullColumns = false;
206
- indexRows.set(key, index);
207
228
  }
208
- return new Map([...indexRows].map(([key, index]) => [key, {
209
- columns: index.columns.sort((left, right) => left.position - right.position).map((column) => column.name),
210
- name: index.name,
211
- table: index.table,
212
- unique: index.unique,
213
- validFullColumns: index.validFullColumns
214
- }]));
229
+ return toDatabaseIndexMap([...indexMetadata.values()]);
215
230
  }
216
231
  async function getDatabaseColumnBounds(db, dbType, schemaName) {
217
232
  if (dbType !== "mysql" && dbType !== "mssql") return /* @__PURE__ */ new Map();
@@ -353,7 +368,7 @@ async function getMigrations(config, { throwOnUnsafe = true } = {}) {
353
368
  if (throwOnUnsafe) throw new UnsafeMigrationError(message);
354
369
  unsafeChanges.push(message);
355
370
  };
356
- let { kysely: db, databaseType: dbType } = await createKyselyAdapter(config);
371
+ let { kysely: db, databaseType: dbType, introspectIndexes } = await createKyselyAdapter(config);
357
372
  if (!dbType) {
358
373
  logger.warn("Could not determine database type, defaulting to sqlite. Please provide a type in the database options to avoid this.");
359
374
  dbType = "sqlite";
@@ -378,7 +393,7 @@ async function getMigrations(config, { throwOnUnsafe = true } = {}) {
378
393
  }
379
394
  } else if (dbType === "mssql") logger.debug(`SQL Server migration: Using schema '${currentSchema}' (from the current user's default schema)`);
380
395
  const allTableMetadata = await db.introspection.getTables();
381
- const databaseIndexes = await getDatabaseIndexes(db, dbType, currentSchema);
396
+ const databaseIndexMap = await getDatabaseIndexMap(db, dbType, currentSchema, allTableMetadata.map((table) => table.name), introspectIndexes);
382
397
  const databaseColumnBounds = await getDatabaseColumnBounds(db, dbType, currentSchema);
383
398
  let tableMetadata = allTableMetadata;
384
399
  if (dbType === "postgres") try {
@@ -405,13 +420,13 @@ async function getMigrations(config, { throwOnUnsafe = true } = {}) {
405
420
  for (const index of value.indexes ?? []) {
406
421
  const name = index.name;
407
422
  const indexKey = createDatabaseIndexKey(key, name);
408
- const existingIndex = databaseIndexes.get(indexKey);
423
+ const existingIndex = databaseIndexMap.get(indexKey);
409
424
  if (existingIndex) {
410
425
  if (!databaseIndexMatches(existingIndex, index)) throw new BetterAuthError(`Database index "${name}" on table "${key}" does not match the configured fields and uniqueness. Rename or replace the existing index, then run the migration again.`);
411
426
  continue;
412
427
  }
413
428
  if (dbType === "sqlite" || dbType === "postgres") {
414
- const indexOnAnotherTable = [...databaseIndexes.values()].find((databaseIndex) => getPortableDatabaseIdentifierKey(databaseIndex.name) === getPortableDatabaseIdentifierKey(name) && getPortableDatabaseIdentifierKey(databaseIndex.table) !== getPortableDatabaseIdentifierKey(key));
429
+ const indexOnAnotherTable = [...databaseIndexMap.values()].find((databaseIndex) => getPortableDatabaseIdentifierKey(databaseIndex.name) === getPortableDatabaseIdentifierKey(name) && getPortableDatabaseIdentifierKey(databaseIndex.table) !== getPortableDatabaseIdentifierKey(key));
415
430
  if (indexOnAnotherTable) throw new BetterAuthError(`Database index name "${name}" is already used by table "${indexOnAnotherTable.table}". Index names must be unique across the schema.`);
416
431
  }
417
432
  const plannedIndex = plannedIndexes.get(indexKey);
@@ -3,7 +3,7 @@ import { getDate } from "../utils/date.mjs";
3
3
  import { assertValidUserInfo, assertValidUserInfoSource } from "../utils/validate-user-info.mjs";
4
4
  import { getStorageOption, processIdentifier } from "./verification-token-storage.mjs";
5
5
  import { getWithHooks } from "./with-hooks.mjs";
6
- import { getCurrentAdapter, getCurrentAuthContext, queueAfterTransactionHook, runWithTransaction } from "@better-auth/core/context";
6
+ import { getCurrentAdapter, getCurrentAuthEndpointContext, queueAfterTransactionHook, runWithTransaction, tryGetCurrentAuthEndpointContext } from "@better-auth/core/context";
7
7
  import { createLocalAccountIssuer } from "@better-auth/core/db";
8
8
  import { APIError, BetterAuthError } from "@better-auth/core/error";
9
9
  import { generateId } from "@better-auth/core/utils/id";
@@ -153,7 +153,7 @@ const createInternalAdapter = (adapter, ctx) => {
153
153
  assertValidUserInfoSource(validationSource);
154
154
  let endpointContext;
155
155
  try {
156
- endpointContext = await getCurrentAuthContext();
156
+ endpointContext = getCurrentAuthEndpointContext();
157
157
  } catch (error) {
158
158
  logger.error("Unable to run validateUserInfo: missing endpoint context", error);
159
159
  throw new APIError("FORBIDDEN", {
@@ -247,7 +247,7 @@ const createInternalAdapter = (adapter, ctx) => {
247
247
  },
248
248
  createSession: async (userId, dontRememberMe, override, overrideAll, storageOptions) => {
249
249
  const headers = await (async () => {
250
- const ctx = await getCurrentAuthContext().catch(() => null);
250
+ const ctx = tryGetCurrentAuthEndpointContext();
251
251
  return ctx?.headers || ctx?.request?.headers;
252
252
  })();
253
253
  const storeInDb = options.session?.storeSessionInDatabase;
@@ -1,10 +1,10 @@
1
- import { getCurrentAdapter, getCurrentAuthContext, queueAfterTransactionHook } from "@better-auth/core/context";
1
+ import { getCurrentAdapter, queueAfterTransactionHook, tryGetCurrentAuthEndpointContext } from "@better-auth/core/context";
2
2
  import { ATTR_CONTEXT, ATTR_DB_COLLECTION_NAME, ATTR_HOOK_TYPE, withSpan } from "@better-auth/core/instrumentation";
3
3
  //#region src/db/with-hooks.ts
4
4
  function getWithHooks(adapter, ctx) {
5
5
  const hooksEntries = ctx.hooks;
6
6
  async function createWithHooks(data, model, customCreateFn) {
7
- const context = await getCurrentAuthContext().catch(() => null);
7
+ const context = tryGetCurrentAuthEndpointContext();
8
8
  let actualData = data;
9
9
  for (const { source, hooks } of hooksEntries) {
10
10
  const toRun = hooks[model]?.create?.before;
@@ -41,7 +41,7 @@ function getWithHooks(adapter, ctx) {
41
41
  return created;
42
42
  }
43
43
  async function updateWithHooks(data, where, model, customUpdateFn) {
44
- const context = await getCurrentAuthContext().catch(() => null);
44
+ const context = tryGetCurrentAuthEndpointContext();
45
45
  let actualData = data;
46
46
  for (const { source, hooks } of hooksEntries) {
47
47
  const toRun = hooks[model]?.update?.before;
@@ -77,7 +77,7 @@ function getWithHooks(adapter, ctx) {
77
77
  return updated;
78
78
  }
79
79
  async function updateManyWithHooks(data, where, model, customUpdateFn) {
80
- const context = await getCurrentAuthContext().catch(() => null);
80
+ const context = tryGetCurrentAuthEndpointContext();
81
81
  let actualData = data;
82
82
  for (const { source, hooks } of hooksEntries) {
83
83
  const toRun = hooks[model]?.update?.before;
@@ -113,7 +113,7 @@ function getWithHooks(adapter, ctx) {
113
113
  return updated;
114
114
  }
115
115
  async function deleteWithHooks(where, model, customDeleteFn) {
116
- const context = await getCurrentAuthContext().catch(() => null);
116
+ const context = tryGetCurrentAuthEndpointContext();
117
117
  let entityToDelete = null;
118
118
  try {
119
119
  entityToDelete = (await (await getCurrentAdapter(adapter)).findMany({
@@ -150,7 +150,7 @@ function getWithHooks(adapter, ctx) {
150
150
  return deleted;
151
151
  }
152
152
  async function deleteManyWithHooks(where, model, customDeleteFn) {
153
- const context = await getCurrentAuthContext().catch(() => null);
153
+ const context = tryGetCurrentAuthEndpointContext();
154
154
  let entitiesToDelete = [];
155
155
  try {
156
156
  entitiesToDelete = await (await getCurrentAdapter(adapter)).findMany({
@@ -202,7 +202,7 @@ function getWithHooks(adapter, ctx) {
202
202
  * the helper resolves to `null` (no `consumeFn` call, no after hooks).
203
203
  */
204
204
  async function consumeOneWithHooks(model, hookWhere, consumeFn, preSnapshot) {
205
- const context = await getCurrentAuthContext().catch(() => null);
205
+ const context = tryGetCurrentAuthEndpointContext();
206
206
  const beforeHooks = hooksEntries.flatMap(({ source, hooks }) => {
207
207
  const fn = hooks[model]?.delete?.before;
208
208
  return fn ? [{
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,3 +1,4 @@
1
+ import { appendQueryParams } from "@better-auth/core/utils/url";
1
2
  //#region src/oauth2/errors.ts
2
3
  /**
3
4
  * Error codes used in OAuth callback redirects (`?error=<code>`). These are
@@ -33,8 +34,8 @@ const HANDLING_DOCS_URL = "https://www.better-auth.com/docs/concepts/oauth#handl
33
34
  function redirectOnError(ctx, errorURL, error, description) {
34
35
  const params = new URLSearchParams({ error });
35
36
  if (description) params.set("error_description", description);
36
- const sep = errorURL.includes("?") ? "&" : "?";
37
- throw ctx.redirect(`${errorURL}${sep}${params.toString()}`);
37
+ const redirectURL = appendQueryParams(errorURL, params);
38
+ throw ctx.redirect(redirectURL);
38
39
  }
39
40
  /**
40
41
  * Build the logger message shown when an OAuth provider does not return an
@@ -42,7 +43,7 @@ function redirectOnError(ctx, errorURL, error, description) {
42
43
  * the same workaround docs.
43
44
  */
44
45
  function missingEmailLogMessage(providerId, options) {
45
- return `${options?.source === "generic" ? `Generic OAuth provider "${providerId}"` : `Provider "${providerId}"`} did not return an email${options?.source === "id_token" ? " in the id token" : ""}. Either request the provider's email scope, or synthesize one via \`mapProfileToUser\`. See ${HANDLING_DOCS_URL}`;
46
+ return `${options?.source === "generic" ? `Generic OAuth provider "${providerId}"` : `Provider "${providerId}"`} did not return an email${options?.source === "id_token" ? " in the id token" : ""}. Either request the provider's email scope, or create a placeholder via \`mapProfileToUser\`. See ${HANDLING_DOCS_URL}`;
46
47
  }
47
48
  //#endregion
48
49
  export { OAUTH_CALLBACK_ERROR_CODES, missingEmailLogMessage, redirectOnError };
@@ -55,7 +55,7 @@ async function parseState(c) {
55
55
  let redirectErrorURL = errorURL;
56
56
  if (error instanceof StateError) {
57
57
  code = error.code === "state_security_mismatch" ? "state_mismatch" : error.code;
58
- redirectErrorURL = error.errorURL ?? errorURL;
58
+ redirectErrorURL = error.errorURL || errorURL;
59
59
  }
60
60
  redirectOnError(c, redirectErrorURL, code);
61
61
  }
package/dist/package.mjs CHANGED
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "1.7.1";
2
+ var version = "1.7.2";
3
3
  //#endregion
4
4
  export { version };
@@ -534,7 +534,7 @@ const banUser = (opts) => createAuthEndpoint("/admin/ban-user", {
534
534
  const user = await ctx.context.internalAdapter.updateUser(ctx.body.userId, {
535
535
  banned: true,
536
536
  banReason: ctx.body.banReason || opts?.defaultBanReason || "No reason",
537
- banExpires: ctx.body.banExpiresIn ? getDate(ctx.body.banExpiresIn, "sec") : opts?.defaultBanExpiresIn ? getDate(opts.defaultBanExpiresIn, "sec") : void 0,
537
+ banExpires: ctx.body.banExpiresIn ? getDate(ctx.body.banExpiresIn, "sec") : opts?.defaultBanExpiresIn ? getDate(opts.defaultBanExpiresIn, "sec") : null,
538
538
  updatedAt: /* @__PURE__ */ new Date()
539
539
  });
540
540
  await ctx.context.internalAdapter.deleteUserSessions(ctx.body.userId);
@@ -10,6 +10,7 @@ import { schema } from "./schema.mjs";
10
10
  import { generateId } from "@better-auth/core/utils/id";
11
11
  import { createAuthEndpoint, createAuthMiddleware } from "@better-auth/core/api";
12
12
  import * as z from "zod";
13
+ import { createPlaceholderEmail } from "@better-auth/core/utils/email";
13
14
  //#region src/plugins/anonymous/index.ts
14
15
  /**
15
16
  * Resolves the anonymous session being upgraded during an account-link callback.
@@ -51,7 +52,10 @@ async function getAnonUserEmail(options) {
51
52
  }
52
53
  const id = generateId();
53
54
  if (options?.emailDomainName) return `temp-${id}@${options.emailDomainName}`;
54
- return `temp@${id}.com`;
55
+ return createPlaceholderEmail({
56
+ identifier: id,
57
+ namespace: "anonymous"
58
+ });
55
59
  }
56
60
  const anonymous = (options) => {
57
61
  return {
@@ -18,9 +18,8 @@ interface UserWithAnonymous extends User {
18
18
  }
19
19
  interface AnonymousOptions {
20
20
  /**
21
- * Configure the domain name of the temporary email
22
- * address for anonymous users in the database.
23
- * @default "baseURL"
21
+ * Configure a custom domain for anonymous user email addresses.
22
+ * @default "anonymous.placeholder.invalid"
24
23
  */
25
24
  emailDomainName?: string | undefined;
26
25
  /**
@@ -1,5 +1,6 @@
1
1
  import { decodeJwt } from "jose";
2
2
  import { betterFetch } from "@better-fetch/fetch";
3
+ import { createPlaceholderEmail } from "@better-auth/core/utils/email";
3
4
  //#region src/plugins/generic-oauth/providers/microsoft-entra-id.ts
4
5
  function getMicrosoftProfileName(profile) {
5
6
  return profile.name ?? (`${profile.given_name ?? profile.givenname ?? ""} ${profile.family_name ?? profile.familyname ?? ""}`.trim() || void 0);
@@ -46,25 +47,34 @@ function microsoftEntraId(options) {
46
47
  } catch {
47
48
  return null;
48
49
  }
49
- if (!(typeof tokenProfile.oid === "string" && tokenProfile.oid.trim().length > 0 ? tokenProfile.oid : void 0)) return null;
50
+ const oid = typeof tokenProfile.oid === "string" && tokenProfile.oid.trim().length > 0 ? tokenProfile.oid : void 0;
51
+ if (!oid) return null;
52
+ const tokenEmail = tokenProfile.email;
50
53
  const tokenUserInfo = {
51
54
  ...tokenProfile,
52
55
  name: getMicrosoftProfileName(tokenProfile),
53
- email: tokenProfile.email ?? tokenProfile.preferred_username ?? void 0,
56
+ email: tokenEmail ?? createPlaceholderEmail({
57
+ identifier: oid,
58
+ namespace: "microsoft-entra-id"
59
+ }),
54
60
  image: tokenProfile.picture,
55
- emailVerified: tokenProfile.email_verified ?? false
61
+ emailVerified: tokenEmail ? tokenProfile.email_verified ?? false : false
56
62
  };
57
63
  if (!tokens.accessToken) return tokenUserInfo;
58
64
  const { data: profile, error } = await betterFetch(userInfoUrl, { headers: { Authorization: `Bearer ${tokens.accessToken}` } });
59
65
  if (error || !profile) return tokenUserInfo;
60
66
  if (typeof tokenProfile.sub !== "string" || profile.sub !== tokenProfile.sub) return tokenUserInfo;
67
+ const emailClaim = tokenProfile.email ?? profile.email;
61
68
  return {
62
69
  ...profile,
63
70
  ...tokenProfile,
64
71
  name: getMicrosoftProfileName(tokenProfile) ?? getMicrosoftProfileName(profile),
65
- email: tokenProfile.email ?? profile.email ?? tokenProfile.preferred_username ?? profile.preferred_username ?? void 0,
72
+ email: emailClaim ?? createPlaceholderEmail({
73
+ identifier: oid,
74
+ namespace: "microsoft-entra-id"
75
+ }),
66
76
  image: tokenProfile.picture ?? profile.picture,
67
- emailVerified: tokenProfile.email_verified ?? profile.email_verified ?? false
77
+ emailVerified: emailClaim != null ? tokenProfile.email_verified ?? profile.email_verified ?? false : false
68
78
  };
69
79
  };
70
80
  return {
@@ -1,7 +1,7 @@
1
1
  import { isAPIError } from "../../utils/is-api-error.mjs";
2
2
  import { APIError } from "../../api/index.mjs";
3
3
  import { PACKAGE_VERSION } from "../../version.mjs";
4
- import { getCurrentAuthContext } from "@better-auth/core/context";
4
+ import { getCurrentAuthEndpointContext } from "@better-auth/core/context";
5
5
  import { defineErrorCodes } from "@better-auth/core/utils/error-codes";
6
6
  import { createHash } from "@better-auth/utils/hash";
7
7
  import { betterFetch } from "@better-fetch/fetch";
@@ -46,7 +46,7 @@ const haveIBeenPwned = (options) => {
46
46
  ...ctx.password,
47
47
  async hash(password) {
48
48
  if (options?.enabled === false) return originalHash(password);
49
- const c = await getCurrentAuthContext();
49
+ const c = getCurrentAuthEndpointContext();
50
50
  if (!c.path || !paths.includes(c.path)) return originalHash(password);
51
51
  await checkPasswordCompromise(password, options?.customPasswordCompromisedMessage);
52
52
  return originalHash(password);
@@ -1,5 +1,5 @@
1
1
  import { getJwksAdapter } from "./adapter.mjs";
2
- import { getCurrentAuthContext } from "@better-auth/core/context";
2
+ import { getCurrentAuthEndpointContext } from "@better-auth/core/context";
3
3
  import { base64 } from "@better-auth/utils/base64";
4
4
  import { importJWK, jwtVerify } from "jose";
5
5
  //#region src/plugins/jwt/verify.ts
@@ -8,7 +8,7 @@ import { importJWK, jwtVerify } from "jose";
8
8
  * Returns the payload if valid, null otherwise
9
9
  */
10
10
  async function verifyJWT(token, options) {
11
- const ctx = await getCurrentAuthContext();
11
+ const ctx = getCurrentAuthEndpointContext();
12
12
  try {
13
13
  const parts = token.split(".");
14
14
  if (parts.length !== 3) return null;
@@ -1,4 +1,5 @@
1
1
  import { HIDE_METADATA } from "../../utils/hide-metadata.mjs";
2
+ import "../../utils/index.mjs";
2
3
  import { APIError } from "../../api/index.mjs";
3
4
  import { PACKAGE_VERSION } from "../../version.mjs";
4
5
  import { generator } from "./generator.mjs";
@@ -4,6 +4,7 @@ import { generateRandomString } from "../../crypto/random.mjs";
4
4
  import { setSessionCookie } from "../../cookies/index.mjs";
5
5
  import { getSessionFromCtx } from "../../api/routes/session.mjs";
6
6
  import { HIDE_METADATA } from "../../utils/hide-metadata.mjs";
7
+ import "../../utils/index.mjs";
7
8
  import { PHONE_NUMBER_ERROR_CODES } from "./error-codes.mjs";
8
9
  import { createLocalAccountIssuer } from "@better-auth/core/db";
9
10
  import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
@@ -1,5 +1,4 @@
1
1
  import { isAPIError } from "../../utils/is-api-error.mjs";
2
- import { getOrigin } from "../../utils/url.mjs";
3
2
  import { mergeSchema } from "../../db/schema.mjs";
4
3
  import { setSessionCookie } from "../../cookies/index.mjs";
5
4
  import { APIError } from "../../api/index.mjs";
@@ -10,6 +9,7 @@ import { schema } from "./schema.mjs";
10
9
  import { createLocalAccountIssuer } from "@better-auth/core/db";
11
10
  import { createAuthEndpoint } from "@better-auth/core/api";
12
11
  import * as z from "zod";
12
+ import { createPlaceholderEmail } from "@better-auth/core/utils/email";
13
13
  //#region src/plugins/siwe/index.ts
14
14
  const signedWalletAddressSchema = z.string().regex(/^0x[a-fA-F0-9]{40}$/).length(42);
15
15
  const SIWE_VERIFICATION_IDENTIFIER_PREFIX = "siwe:";
@@ -162,9 +162,11 @@ const siwe = (options) => {
162
162
  });
163
163
  }
164
164
  if (!user) {
165
- const domain = options.emailDomainName ?? getOrigin(ctx.context.baseURL);
166
165
  const normalizedEmail = email?.toLowerCase();
167
- const walletEmail = `${walletAddress}@${domain}`;
166
+ const walletEmail = options.emailDomainName ? `${walletAddress}@${options.emailDomainName}` : createPlaceholderEmail({
167
+ identifier: walletAddress,
168
+ namespace: "siwe"
169
+ });
168
170
  let userEmail = walletEmail;
169
171
  let emailClaimIdentifier;
170
172
  if (!isAnon && normalizedEmail) {
@@ -61,7 +61,7 @@ declare function getHttpTestInstance<O extends Partial<BetterAuthOptions>, C ext
61
61
  port: number;
62
62
  auth: Auth<O>;
63
63
  client: {
64
- hydrateSession: (session: null) => void;
64
+ hydrateSession(session: null): void;
65
65
  useSession: _$nanostores.Atom<{
66
66
  data: never;
67
67
  error: _$_better_fetch_fetch0.BetterFetchError | null;
@@ -7,7 +7,7 @@ import { createAuthClient } from "../client/vanilla.mjs";
7
7
  import { bearer } from "../plugins/bearer/index.mjs";
8
8
  import { sql } from "kysely";
9
9
  import { AsyncLocalStorage } from "node:async_hooks";
10
- import { randomUUID } from "node:crypto";
10
+ import { createHash, randomUUID } from "node:crypto";
11
11
  import { afterAll } from "vitest";
12
12
  //#region src/test-utils/test-instance.ts
13
13
  const cleanupSet = /* @__PURE__ */ new Set();
@@ -18,6 +18,17 @@ afterAll(async () => {
18
18
  cleanupSet.delete(cleanup);
19
19
  }
20
20
  });
21
+ const TEST_PASSWORD_HASH_PREFIX = "$test$sha256$";
22
+ /**
23
+ * Intentionally fast for tests
24
+ */
25
+ function createTestPasswordHash(password) {
26
+ return `${TEST_PASSWORD_HASH_PREFIX}${createHash("sha256").update(password.normalize("NFKC")).digest("hex")}`;
27
+ }
28
+ const testPassword = {
29
+ hash: async (password) => createTestPasswordHash(password),
30
+ verify: async ({ hash, password }) => hash === createTestPasswordHash(password)
31
+ };
21
32
  async function getTestInstance(options, config) {
22
33
  const testWith = config?.testWith || "sqlite";
23
34
  const postgresSchema = testWith === "postgres" ? `ba_test_${randomUUID().replaceAll("-", "_")}` : void 0;
@@ -83,6 +94,11 @@ async function getTestInstance(options, config) {
83
94
  baseURL: "http://localhost:" + (config?.port || 3e3),
84
95
  ...opts,
85
96
  ...options,
97
+ emailAndPassword: {
98
+ ...opts.emailAndPassword,
99
+ ...options?.emailAndPassword,
100
+ password: options?.emailAndPassword?.password ?? testPassword
101
+ },
86
102
  plugins: [bearer(), ...options?.plugins || []]
87
103
  });
88
104
  const testUser = {
@@ -0,0 +1,5 @@
1
+ import "./url.mjs";
2
+ import "../state.mjs";
3
+ import "../oauth2/state.mjs";
4
+ import "./hide-metadata.mjs";
5
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "better-auth",
3
- "version": "1.7.1",
3
+ "version": "1.7.2",
4
4
  "description": "The most comprehensive authentication framework for TypeScript.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -473,13 +473,13 @@
473
473
  "kysely": "^0.28.17 || ^0.29.0",
474
474
  "nanostores": "^1.3.0",
475
475
  "zod": "^4.3.6",
476
- "@better-auth/core": "1.7.1",
477
- "@better-auth/drizzle-adapter": "1.7.1",
478
- "@better-auth/kysely-adapter": "1.7.1",
479
- "@better-auth/memory-adapter": "1.7.1",
480
- "@better-auth/mongo-adapter": "1.7.1",
481
- "@better-auth/prisma-adapter": "1.7.1",
482
- "@better-auth/telemetry": "1.7.1"
476
+ "@better-auth/core": "1.7.2",
477
+ "@better-auth/drizzle-adapter": "1.7.2",
478
+ "@better-auth/kysely-adapter": "1.7.2",
479
+ "@better-auth/memory-adapter": "1.7.2",
480
+ "@better-auth/mongo-adapter": "1.7.2",
481
+ "@better-auth/prisma-adapter": "1.7.2",
482
+ "@better-auth/telemetry": "1.7.2"
483
483
  },
484
484
  "devDependencies": {
485
485
  "@lynx-js/react": "^0.121.2",
@@ -504,7 +504,7 @@
504
504
  "tsdown": "0.21.10",
505
505
  "type-fest": "^5.7.0",
506
506
  "typescript": "^6.0.3",
507
- "vitest": "^4.0.18",
507
+ "vitest": "^4.1.10",
508
508
  "vue": "^3.5.29"
509
509
  },
510
510
  "peerDependencies": {