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.
- package/dist/api/middlewares/origin-check.mjs +9 -5
- package/dist/api/routes/callback.mjs +7 -5
- package/dist/api/routes/email-verification.mjs +4 -2
- package/dist/api/routes/error.mjs +12 -8
- package/dist/api/routes/sign-in.mjs +1 -0
- package/dist/auth/trusted-origins.mjs +21 -4
- package/dist/client/lynx/index.d.mts +1 -1
- package/dist/client/react/index.d.mts +1 -1
- package/dist/client/solid/index.d.mts +1 -1
- package/dist/client/svelte/index.d.mts +1 -1
- package/dist/client/vanilla.d.mts +1 -1
- package/dist/client/vue/index.d.mts +1 -1
- package/dist/cookies/cache.mjs +10 -2
- package/dist/db/get-migration.d.mts +29 -2
- package/dist/db/get-migration.mjs +125 -48
- package/dist/db/internal-adapter.mjs +3 -3
- package/dist/db/with-hooks.mjs +7 -7
- package/dist/index.mjs +1 -0
- package/dist/oauth2/errors.mjs +4 -3
- package/dist/oauth2/state.mjs +1 -1
- package/dist/package.mjs +1 -1
- package/dist/plugins/admin/routes.mjs +1 -1
- package/dist/plugins/anonymous/index.mjs +5 -1
- package/dist/plugins/anonymous/types.d.mts +2 -3
- package/dist/plugins/generic-oauth/providers/microsoft-entra-id.mjs +15 -5
- package/dist/plugins/haveibeenpwned/index.mjs +2 -2
- package/dist/plugins/jwt/verify.mjs +2 -2
- package/dist/plugins/open-api/index.mjs +1 -0
- package/dist/plugins/phone-number/routes.mjs +1 -0
- package/dist/plugins/siwe/index.mjs +5 -3
- package/dist/test-utils/http-test-instance.d.mts +1 -1
- package/dist/test-utils/test-instance.mjs +17 -1
- package/dist/utils/index.mjs +5 -0
- 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
|
|
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
|
-
|
|
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(
|
|
110
|
-
ctx.context.logger.error(`Invalid origin: ${
|
|
111
|
-
ctx.context.logger.info(`If it's a valid URL, please add ${
|
|
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
|
-
|
|
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
|
|
75
|
-
throw c.redirect(
|
|
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
|
|
83
|
-
throw c.redirect(
|
|
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
|
-
|
|
169
|
-
|
|
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
|
|
368
|
-
|
|
369
|
-
if (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)
|
|
373
|
-
|
|
374
|
-
|
|
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: `/?${
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
45
|
+
hydrateSession(session: NonNullable<ClientSession<Option>> | null): void;
|
|
46
46
|
useSession: VueUseSession<Option>;
|
|
47
47
|
$Infer: {
|
|
48
48
|
Session: NonNullable<ClientSession<Option>>;
|
package/dist/cookies/cache.mjs
CHANGED
|
@@ -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
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
194
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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 = [...
|
|
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:
|
|
563
|
+
schema: authTables,
|
|
505
564
|
usePlural: false
|
|
506
565
|
});
|
|
507
566
|
const getFieldName = initGetFieldName({
|
|
508
|
-
schema:
|
|
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)
|
|
534
|
-
const
|
|
535
|
-
const
|
|
536
|
-
|
|
537
|
-
const
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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
|
-
|
|
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,
|
|
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 =
|
|
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 =
|
|
250
|
+
const ctx = tryGetCurrentAuthEndpointContext();
|
|
251
251
|
return ctx?.headers || ctx?.request?.headers;
|
|
252
252
|
})();
|
|
253
253
|
const storeInDb = options.session?.storeSessionInDatabase;
|
package/dist/db/with-hooks.mjs
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { getCurrentAdapter,
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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";
|
package/dist/oauth2/errors.mjs
CHANGED
|
@@ -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
|
|
37
|
-
throw ctx.redirect(
|
|
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
|
|
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 };
|
package/dist/oauth2/state.mjs
CHANGED
|
@@ -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
|
|
58
|
+
redirectErrorURL = error.errorURL || errorURL;
|
|
59
59
|
}
|
|
60
60
|
redirectOnError(c, redirectErrorURL, code);
|
|
61
61
|
}
|
package/dist/package.mjs
CHANGED
|
@@ -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") :
|
|
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
|
|
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
|
|
22
|
-
*
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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 {
|
|
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 =
|
|
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 {
|
|
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 =
|
|
11
|
+
const ctx = getCurrentAuthEndpointContext();
|
|
12
12
|
try {
|
|
13
13
|
const parts = token.split(".");
|
|
14
14
|
if (parts.length !== 3) return null;
|
|
@@ -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}@${
|
|
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
|
|
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 = {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "better-auth",
|
|
3
|
-
"version": "1.7.
|
|
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.
|
|
477
|
-
"@better-auth/drizzle-adapter": "1.7.
|
|
478
|
-
"@better-auth/kysely-adapter": "1.7.
|
|
479
|
-
"@better-auth/memory-adapter": "1.7.
|
|
480
|
-
"@better-auth/mongo-adapter": "1.7.
|
|
481
|
-
"@better-auth/prisma-adapter": "1.7.
|
|
482
|
-
"@better-auth/telemetry": "1.7.
|
|
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.
|
|
507
|
+
"vitest": "^4.1.10",
|
|
508
508
|
"vue": "^3.5.29"
|
|
509
509
|
},
|
|
510
510
|
"peerDependencies": {
|