better-auth 1.7.0 → 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.
Files changed (34) hide show
  1. package/dist/api/middlewares/origin-check.mjs +9 -5
  2. package/dist/api/routes/callback.mjs +7 -5
  3. package/dist/api/routes/email-verification.mjs +4 -2
  4. package/dist/api/routes/error.mjs +12 -8
  5. package/dist/api/routes/sign-in.mjs +1 -0
  6. package/dist/auth/trusted-origins.mjs +21 -4
  7. package/dist/client/lynx/index.d.mts +1 -1
  8. package/dist/client/react/index.d.mts +1 -1
  9. package/dist/client/solid/index.d.mts +1 -1
  10. package/dist/client/svelte/index.d.mts +1 -1
  11. package/dist/client/vanilla.d.mts +1 -1
  12. package/dist/client/vue/index.d.mts +1 -1
  13. package/dist/cookies/cache.mjs +10 -2
  14. package/dist/db/get-migration.d.mts +29 -2
  15. package/dist/db/get-migration.mjs +125 -48
  16. package/dist/db/internal-adapter.mjs +3 -3
  17. package/dist/db/with-hooks.mjs +7 -7
  18. package/dist/index.mjs +1 -0
  19. package/dist/oauth2/errors.mjs +4 -3
  20. package/dist/oauth2/state.mjs +1 -1
  21. package/dist/package.mjs +1 -1
  22. package/dist/plugins/admin/routes.mjs +1 -1
  23. package/dist/plugins/anonymous/index.mjs +5 -1
  24. package/dist/plugins/anonymous/types.d.mts +2 -3
  25. package/dist/plugins/generic-oauth/providers/microsoft-entra-id.mjs +15 -5
  26. package/dist/plugins/haveibeenpwned/index.mjs +2 -2
  27. package/dist/plugins/jwt/verify.mjs +2 -2
  28. package/dist/plugins/open-api/index.mjs +1 -0
  29. package/dist/plugins/phone-number/routes.mjs +1 -0
  30. package/dist/plugins/siwe/index.mjs +5 -3
  31. package/dist/test-utils/http-test-instance.d.mts +1 -1
  32. package/dist/test-utils/test-instance.mjs +17 -1
  33. package/dist/utils/index.mjs +5 -0
  34. package/package.json +9 -9
@@ -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);
@@ -1,11 +1,37 @@
1
1
  import { BetterAuthOptions } from "@better-auth/core";
2
2
  import { DBFieldAttribute, DBFieldType } from "@better-auth/core/db";
3
+ import { BetterAuthError } from "@better-auth/core/error";
3
4
  import { KyselyDatabaseType } from "@better-auth/kysely-adapter";
4
5
  import { ResolvedDBTableIndex } from "@better-auth/core/db/internal";
5
6
 
6
7
  //#region src/db/get-migration.d.ts
8
+ /**
9
+ * Thrown when {@link getMigrations} refuses to add a required column with no
10
+ * default value to a populated table. Distinct from the plain
11
+ * {@link BetterAuthError} thrown for index-definition conflicts, so callers
12
+ * can tell the two apart without matching on message text.
13
+ */
14
+ declare class UnsafeMigrationError extends BetterAuthError {}
7
15
  declare function matchType(columnDataType: string, fieldType: DBFieldType, dbType: KyselyDatabaseType): boolean;
8
- declare function getMigrations(config: BetterAuthOptions): Promise<{
16
+ /**
17
+ * Build the migration plan that `auth migrate` executes and `auth generate`
18
+ * prints for the Kysely adapter.
19
+ *
20
+ * Adding a required column without a default to a populated table is refused:
21
+ * existing rows have no value to backfill. `throwOnUnsafe` picks how that
22
+ * refusal is delivered: executing callers get an {@link UnsafeMigrationError},
23
+ * read-only callers get the plan plus the same message in `unsafeChanges`.
24
+ *
25
+ * @throws {UnsafeMigrationError} when a required column cannot be migrated
26
+ * safely and `throwOnUnsafe` is left on.
27
+ * @throws {BetterAuthError} when an index definition conflicts with an
28
+ * existing or already-planned index.
29
+ */
30
+ declare function getMigrations(config: BetterAuthOptions, {
31
+ throwOnUnsafe
32
+ }?: {
33
+ throwOnUnsafe?: boolean;
34
+ }): Promise<{
9
35
  toBeCreated: {
10
36
  table: string;
11
37
  fields: Record<string, DBFieldAttribute>;
@@ -21,8 +47,9 @@ declare function getMigrations(config: BetterAuthOptions): Promise<{
21
47
  index: ResolvedDBTableIndex;
22
48
  name: string;
23
49
  }[];
50
+ unsafeChanges: string[];
24
51
  runMigrations: () => Promise<void>;
25
52
  compileMigrations: () => Promise<string>;
26
53
  }>;
27
54
  //#endregion
28
- export { getMigrations, matchType };
55
+ export { UnsafeMigrationError, getMigrations, matchType };
@@ -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();
@@ -278,6 +293,24 @@ function assertExistingTableIndexFits({ columnBounds, dbType, existingColumns, f
278
293
  }
279
294
  if (requiredBytes > byteBudget) throw new BetterAuthError(`Cannot create database index "${index.name}" on existing table "${table}" because its columns can exceed ${dbType === "mysql" ? "MySQL" : "SQL Server"}'s ${byteBudget}-byte index-key limit. Bound the indexed string columns to the generated schema lengths, resolve oversized values, then run the migration again.`);
280
295
  }
296
+ const columnBackfillGuideUrl = "https://better-auth.com/docs/guides/1-7-upgrade-guide#account-identity-is-scoped-by-issuer";
297
+ /**
298
+ * Thrown when {@link getMigrations} refuses to add a required column with no
299
+ * default value to a populated table. Distinct from the plain
300
+ * {@link BetterAuthError} thrown for index-definition conflicts, so callers
301
+ * can tell the two apart without matching on message text.
302
+ */
303
+ var UnsafeMigrationError = class extends BetterAuthError {};
304
+ function hasTimestampColumnDefault(field, dbType) {
305
+ return field.type === "date" && typeof field.defaultValue === "function" && (dbType === "postgres" || dbType === "mysql" || dbType === "mssql");
306
+ }
307
+ function hasStaticColumnDefault(field) {
308
+ return !(field.unique && field.required === false) && (field.type === "string" || field.type === "number" || field.type === "boolean") && field.defaultValue !== void 0 && field.defaultValue !== null && typeof field.defaultValue !== "function";
309
+ }
310
+ async function tableHasRows(db, dbType, table) {
311
+ const probe = db.selectFrom(table).select(sql`1`.as("present"));
312
+ return (await (dbType === "mssql" ? probe.top(1) : probe.limit(1)).execute()).length > 0;
313
+ }
281
314
  function matchType(columnDataType, fieldType, dbType) {
282
315
  function normalize(type) {
283
316
  return type.toLowerCase().split("(")[0].trim();
@@ -307,10 +340,35 @@ async function getMssqlSchema(db) {
307
340
  return "dbo";
308
341
  }
309
342
  }
310
- async function getMigrations(config) {
343
+ /**
344
+ * Build the migration plan that `auth migrate` executes and `auth generate`
345
+ * prints for the Kysely adapter.
346
+ *
347
+ * Adding a required column without a default to a populated table is refused:
348
+ * existing rows have no value to backfill. `throwOnUnsafe` picks how that
349
+ * refusal is delivered: executing callers get an {@link UnsafeMigrationError},
350
+ * read-only callers get the plan plus the same message in `unsafeChanges`.
351
+ *
352
+ * @throws {UnsafeMigrationError} when a required column cannot be migrated
353
+ * safely and `throwOnUnsafe` is left on.
354
+ * @throws {BetterAuthError} when an index definition conflicts with an
355
+ * existing or already-planned index.
356
+ */
357
+ async function getMigrations(config, { throwOnUnsafe = true } = {}) {
311
358
  const betterAuthSchema = getSchema(config);
359
+ const authTables = getAuthTables(config);
360
+ const accountIssuer = authTables.account && {
361
+ table: authTables.account.modelName,
362
+ column: authTables.account.fields.issuer?.fieldName || "issuer"
363
+ };
364
+ const isAccountIssuerColumn = (table, column) => table === accountIssuer?.table && column === accountIssuer.column;
312
365
  const logger = createLogger(config.logger);
313
- let { kysely: db, databaseType: dbType } = await createKyselyAdapter(config);
366
+ const unsafeChanges = [];
367
+ const reportUnsafeChange = (message) => {
368
+ if (throwOnUnsafe) throw new UnsafeMigrationError(message);
369
+ unsafeChanges.push(message);
370
+ };
371
+ let { kysely: db, databaseType: dbType, introspectIndexes } = await createKyselyAdapter(config);
314
372
  if (!dbType) {
315
373
  logger.warn("Could not determine database type, defaulting to sqlite. Please provide a type in the database options to avoid this.");
316
374
  dbType = "sqlite";
@@ -335,7 +393,7 @@ async function getMigrations(config) {
335
393
  }
336
394
  } else if (dbType === "mssql") logger.debug(`SQL Server migration: Using schema '${currentSchema}' (from the current user's default schema)`);
337
395
  const allTableMetadata = await db.introspection.getTables();
338
- const databaseIndexes = await getDatabaseIndexes(db, dbType, currentSchema);
396
+ const databaseIndexMap = await getDatabaseIndexMap(db, dbType, currentSchema, allTableMetadata.map((table) => table.name), introspectIndexes);
339
397
  const databaseColumnBounds = await getDatabaseColumnBounds(db, dbType, currentSchema);
340
398
  let tableMetadata = allTableMetadata;
341
399
  if (dbType === "postgres") try {
@@ -362,13 +420,13 @@ async function getMigrations(config) {
362
420
  for (const index of value.indexes ?? []) {
363
421
  const name = index.name;
364
422
  const indexKey = createDatabaseIndexKey(key, name);
365
- const existingIndex = databaseIndexes.get(indexKey);
423
+ const existingIndex = databaseIndexMap.get(indexKey);
366
424
  if (existingIndex) {
367
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.`);
368
426
  continue;
369
427
  }
370
428
  if (dbType === "sqlite" || dbType === "postgres") {
371
- 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));
372
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.`);
373
431
  }
374
432
  const plannedIndex = plannedIndexes.get(indexKey);
@@ -421,6 +479,7 @@ async function getMigrations(config) {
421
479
  toBeAddedFields[fieldName] = field;
422
480
  continue;
423
481
  }
482
+ if (field.required !== false && column.isNullable) logger.warn(`Column "${fieldName}" on table "${key}" stays nullable while the schema declares the field required, so existing rows can still hold null. Backfill every row for this column and enforce NOT NULL to remove the drift.`);
424
483
  if (matchType(column.dataType, field.type, dbType)) continue;
425
484
  else logger.warn(`Field ${fieldName} in table ${key} has a different type in the database. Expected ${field.type} but got ${column.dataType}.`);
426
485
  }
@@ -501,11 +560,11 @@ async function getMigrations(config) {
501
560
  return typeMap[type][provider];
502
561
  }
503
562
  const getModelName = initGetModelName({
504
- schema: getAuthTables(config),
563
+ schema: authTables,
505
564
  usePlural: false
506
565
  });
507
566
  const getFieldName = initGetFieldName({
508
- schema: getAuthTables(config),
567
+ schema: authTables,
509
568
  usePlural: false
510
569
  });
511
570
  function getReferencePath(model, field) {
@@ -530,28 +589,45 @@ async function getMigrations(config) {
530
589
  indexes: table.indexes ?? []
531
590
  });
532
591
  };
533
- if (toBeAdded.length) for (const table of toBeAdded) for (const [fieldName, field] of Object.entries(table.fields)) {
534
- const type = getType(field, fieldName, getTableIndexStringLength(table.table, fieldName));
535
- const builder = db.schema.alterTable(table.table);
536
- if (field.index || field.unique) {
537
- const indexName = getDatabaseFieldIndexName(table.table, fieldName, field.unique ?? false);
538
- let indexBuilder = db.schema.createIndex(indexName).on(table.table).columns([fieldName]);
539
- if (field.unique) {
540
- indexBuilder = indexBuilder.unique();
541
- if (field.required === false && dbType === "mssql") indexBuilder = indexBuilder.where(fieldName, "is not", null);
542
- if (field.required !== false && field.defaultValue !== void 0 && field.defaultValue !== null && typeof field.defaultValue !== "function") logger.warn(`Adding unique column "${fieldName}" to existing table "${table.table}" backfills every existing row with its default value. If the table has more than one row, creating the unique index "${indexName}" will fail; backfill distinct values manually, then re-run the migration or create the index yourself.`);
592
+ if (toBeAdded.length) {
593
+ const populatedTables = /* @__PURE__ */ new Map();
594
+ for (const table of toBeAdded) for (const [fieldName, field] of Object.entries(table.fields)) {
595
+ const timestampDefault = hasTimestampColumnDefault(field, dbType);
596
+ const staticDefault = hasStaticColumnDefault(field);
597
+ if (field.required !== false && !timestampDefault && !staticDefault) {
598
+ let populated = populatedTables.get(table.table);
599
+ if (populated === void 0) {
600
+ populated = await tableHasRows(db, dbType, table.table);
601
+ populatedTables.set(table.table, populated);
602
+ }
603
+ if (populated) {
604
+ const textDetail = field.type === "string" ? " For a text column, every existing row ends up with the same empty string." : "";
605
+ const guideLink = isAccountIssuerColumn(table.table, fieldName) ? ` See ${columnBackfillGuideUrl}` : "";
606
+ reportUnsafeChange(`Cannot add required column "${fieldName}" to populated table "${table.table}": the schema declares no default value, so existing rows have no value to backfill. MySQL accepts this statement instead of rejecting it and fills every existing row with an implicit default for the column type, reporting a successful migration over corrupted data.${textDetail} Add the column as nullable, backfill a correct value for every row, then make it NOT NULL.${guideLink}`);
607
+ }
543
608
  }
544
- deferredIndexes.push(indexBuilder);
609
+ const type = getType(field, fieldName, getTableIndexStringLength(table.table, fieldName));
610
+ const builder = db.schema.alterTable(table.table);
611
+ if (field.index || field.unique) {
612
+ const indexName = getDatabaseFieldIndexName(table.table, fieldName, field.unique ?? false);
613
+ let indexBuilder = db.schema.createIndex(indexName).on(table.table).columns([fieldName]);
614
+ if (field.unique) {
615
+ indexBuilder = indexBuilder.unique();
616
+ if (field.required === false && dbType === "mssql") indexBuilder = indexBuilder.where(fieldName, "is not", null);
617
+ if (field.required !== false && field.defaultValue !== void 0 && field.defaultValue !== null && typeof field.defaultValue !== "function") logger.warn(`Adding unique column "${fieldName}" to existing table "${table.table}" backfills every existing row with its default value. If the table has more than one row, creating the unique index "${indexName}" will fail; backfill distinct values manually, then re-run the migration or create the index yourself.`);
618
+ }
619
+ deferredIndexes.push(indexBuilder);
620
+ }
621
+ const built = builder.addColumn(fieldName, type, (col) => {
622
+ col = field.required !== false ? col.notNull() : col;
623
+ if (field.references) col = col.references(getReferencePath(field.references.model, field.references.field)).onDelete(field.references.onDelete || "cascade");
624
+ if (timestampDefault) if (dbType === "mysql") col = col.defaultTo(sql`CURRENT_TIMESTAMP(3)`);
625
+ else col = col.defaultTo(sql`CURRENT_TIMESTAMP`);
626
+ else if (staticDefault) col = col.defaultTo(typeof field.defaultValue === "boolean" && (dbType === "sqlite" || dbType === "mssql") ? field.defaultValue ? 1 : 0 : field.defaultValue);
627
+ return col;
628
+ });
629
+ migrations.push(built);
545
630
  }
546
- const built = builder.addColumn(fieldName, type, (col) => {
547
- col = field.required !== false ? col.notNull() : col;
548
- if (field.references) col = col.references(getReferencePath(field.references.model, field.references.field)).onDelete(field.references.onDelete || "cascade");
549
- if (field.type === "date" && typeof field.defaultValue === "function" && (dbType === "postgres" || dbType === "mysql" || dbType === "mssql")) if (dbType === "mysql") col = col.defaultTo(sql`CURRENT_TIMESTAMP(3)`);
550
- else col = col.defaultTo(sql`CURRENT_TIMESTAMP`);
551
- else if (!(field.unique && field.required === false) && (field.type === "string" || field.type === "number" || field.type === "boolean") && field.defaultValue !== void 0 && field.defaultValue !== null && typeof field.defaultValue !== "function") col = col.defaultTo(typeof field.defaultValue === "boolean" && (dbType === "sqlite" || dbType === "mssql") ? field.defaultValue ? 1 : 0 : field.defaultValue);
552
- return col;
553
- });
554
- migrations.push(built);
555
631
  }
556
632
  if (toBeCreated.length) for (const table of toBeCreated) {
557
633
  const idType = getType({ type: useNumberId ? "number" : "string" }, "id");
@@ -601,9 +677,10 @@ async function getMigrations(config) {
601
677
  toBeCreated,
602
678
  toBeAdded,
603
679
  toBeAddedIndexes,
680
+ unsafeChanges,
604
681
  runMigrations,
605
682
  compileMigrations
606
683
  };
607
684
  }
608
685
  //#endregion
609
- export { getMigrations, matchType };
686
+ export { UnsafeMigrationError, getMigrations, matchType };
@@ -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.0";
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.0",
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.0",
477
- "@better-auth/drizzle-adapter": "1.7.0",
478
- "@better-auth/kysely-adapter": "1.7.0",
479
- "@better-auth/memory-adapter": "1.7.0",
480
- "@better-auth/mongo-adapter": "1.7.0",
481
- "@better-auth/prisma-adapter": "1.7.0",
482
- "@better-auth/telemetry": "1.7.0"
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": {