better-auth 1.6.12 → 1.6.14
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/index.d.mts +4 -46
- package/dist/api/routes/account.d.mts +2 -23
- package/dist/api/routes/account.mjs +94 -73
- package/dist/api/routes/callback.mjs +3 -2
- package/dist/api/routes/password.mjs +1 -1
- package/dist/api/routes/session.mjs +1 -1
- package/dist/api/routes/sign-in.mjs +1 -1
- package/dist/api/routes/update-user.mjs +3 -3
- package/dist/client/fetch-plugins.mjs +2 -1
- package/dist/context/create-context.mjs +10 -14
- package/dist/cookies/index.mjs +1 -1
- package/dist/db/internal-adapter.mjs +19 -20
- package/dist/db/to-zod.d.mts +2 -2
- package/dist/db/to-zod.mjs +1 -1
- package/dist/oauth2/index.d.mts +2 -2
- package/dist/oauth2/index.mjs +3 -3
- package/dist/oauth2/link-account.d.mts +27 -1
- package/dist/oauth2/link-account.mjs +24 -1
- package/dist/package.mjs +1 -1
- package/dist/plugins/admin/routes.mjs +3 -3
- package/dist/plugins/anonymous/index.mjs +2 -2
- package/dist/plugins/email-otp/routes.mjs +1 -1
- package/dist/plugins/generic-oauth/routes.mjs +3 -2
- package/dist/plugins/mcp/index.mjs +2 -1
- package/dist/plugins/oauth-proxy/index.mjs +1 -1
- package/dist/plugins/oidc-provider/index.mjs +2 -1
- package/dist/plugins/one-tap/client.mjs +9 -2
- package/dist/plugins/one-tap/index.mjs +16 -39
- package/dist/plugins/organization/adapter.mjs +2 -0
- package/dist/plugins/organization/routes/crud-access-control.d.mts +1 -1
- package/dist/plugins/organization/routes/crud-invites.mjs +30 -4
- package/dist/plugins/organization/routes/crud-org.d.mts +4 -4
- package/dist/plugins/organization/routes/crud-org.mjs +2 -2
- package/dist/plugins/organization/types.d.mts +21 -14
- package/dist/plugins/phone-number/routes.mjs +1 -1
- package/dist/plugins/two-factor/backup-codes/index.d.mts +4 -3
- package/dist/plugins/two-factor/client.mjs +2 -1
- package/dist/test-utils/test-instance.d.mts +12 -138
- package/package.json +8 -8
|
@@ -46,6 +46,7 @@ async function handleOAuthUserInfo(c, opts) {
|
|
|
46
46
|
};
|
|
47
47
|
}
|
|
48
48
|
if (userInfo.emailVerified && !dbUser.user.emailVerified && userInfo.email.toLowerCase() === dbUser.user.email) await c.context.internalAdapter.updateUser(dbUser.user.id, { emailVerified: true });
|
|
49
|
+
user = await applyUpdateUserInfoOnLink(c, dbUser.user.id, userInfo) ?? user;
|
|
49
50
|
} else {
|
|
50
51
|
const freshTokens = c.context.options.account?.updateAccountOnSignIn !== false ? Object.fromEntries(Object.entries({
|
|
51
52
|
idToken: account.idToken,
|
|
@@ -137,5 +138,27 @@ async function handleOAuthUserInfo(c, opts) {
|
|
|
137
138
|
isRegister
|
|
138
139
|
};
|
|
139
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* Apply the `account.accountLinking.updateUserInfoOnLink` policy: when enabled,
|
|
143
|
+
* copy the freshly linked provider's profile onto the local user, matching the
|
|
144
|
+
* field set persisted on sign-up. The local `email` and `emailVerified` are
|
|
145
|
+
* never changed, so a link can't rebind the account's identity, and
|
|
146
|
+
* `updateUser` drops `undefined` fields, so a provider that omits one leaves
|
|
147
|
+
* the existing column intact.
|
|
148
|
+
*
|
|
149
|
+
* Returns the updated user so a caller that issues a session can seed the
|
|
150
|
+
* cookie cache with the fresh row. Returns `undefined` when the policy is
|
|
151
|
+
* disabled or the update fails: a failed profile sync must not abort the link.
|
|
152
|
+
*/
|
|
153
|
+
async function applyUpdateUserInfoOnLink(c, userId, userInfo) {
|
|
154
|
+
if (c.context.options.account?.accountLinking?.updateUserInfoOnLink !== true) return;
|
|
155
|
+
const { id: _id, email: _email, emailVerified: _emailVerified, ...profile } = userInfo;
|
|
156
|
+
try {
|
|
157
|
+
return await c.context.internalAdapter.updateUser(userId, profile);
|
|
158
|
+
} catch (e) {
|
|
159
|
+
c.context.logger.warn("Could not update user info on account link", e);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
140
163
|
//#endregion
|
|
141
|
-
export { handleOAuthUserInfo };
|
|
164
|
+
export { applyUpdateUserInfoOnLink, handleOAuthUserInfo };
|
package/dist/package.mjs
CHANGED
|
@@ -462,7 +462,7 @@ const banUser = (opts) => createAuthEndpoint("/admin/ban-user", {
|
|
|
462
462
|
banExpires: ctx.body.banExpiresIn ? getDate(ctx.body.banExpiresIn, "sec") : opts?.defaultBanExpiresIn ? getDate(opts.defaultBanExpiresIn, "sec") : void 0,
|
|
463
463
|
updatedAt: /* @__PURE__ */ new Date()
|
|
464
464
|
});
|
|
465
|
-
await ctx.context.internalAdapter.
|
|
465
|
+
await ctx.context.internalAdapter.deleteUserSessions(ctx.body.userId);
|
|
466
466
|
return ctx.json({ user: parseUserOutput(ctx.context.options, user) });
|
|
467
467
|
});
|
|
468
468
|
const impersonateUserBodySchema = z.object({ userId: z.coerce.string().meta({ description: "The user id" }) });
|
|
@@ -658,7 +658,7 @@ const revokeUserSessions = (opts) => createAuthEndpoint("/admin/revoke-user-sess
|
|
|
658
658
|
options: opts,
|
|
659
659
|
permissions: { session: ["revoke"] }
|
|
660
660
|
})) throw APIError.from("FORBIDDEN", ADMIN_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_REVOKE_USERS_SESSIONS);
|
|
661
|
-
await ctx.context.internalAdapter.
|
|
661
|
+
await ctx.context.internalAdapter.deleteUserSessions(ctx.body.userId);
|
|
662
662
|
return ctx.json({ success: true });
|
|
663
663
|
});
|
|
664
664
|
const removeUserBodySchema = z.object({ userId: z.coerce.string().meta({ description: "The user id" }) });
|
|
@@ -703,7 +703,7 @@ const removeUser = (opts) => createAuthEndpoint("/admin/remove-user", {
|
|
|
703
703
|
})) throw APIError.from("FORBIDDEN", ADMIN_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_DELETE_USERS);
|
|
704
704
|
if (ctx.body.userId === ctx.context.session.user.id) throw APIError.from("BAD_REQUEST", ADMIN_ERROR_CODES.YOU_CANNOT_REMOVE_YOURSELF);
|
|
705
705
|
if (!await ctx.context.internalAdapter.findUserById(ctx.body.userId)) throw APIError.from("NOT_FOUND", BASE_ERROR_CODES.USER_NOT_FOUND);
|
|
706
|
-
await ctx.context.internalAdapter.
|
|
706
|
+
await ctx.context.internalAdapter.deleteUserSessions(ctx.body.userId);
|
|
707
707
|
await ctx.context.internalAdapter.deleteUser(ctx.body.userId);
|
|
708
708
|
return ctx.json({ success: true });
|
|
709
709
|
});
|
|
@@ -102,7 +102,7 @@ const anonymous = (options) => {
|
|
|
102
102
|
if (options?.disableDeleteAnonymousUser) throw APIError.from("BAD_REQUEST", ANONYMOUS_ERROR_CODES.DELETE_ANONYMOUS_USER_DISABLED);
|
|
103
103
|
if (!session.user.isAnonymous) throw APIError.from("FORBIDDEN", ANONYMOUS_ERROR_CODES.USER_IS_NOT_ANONYMOUS);
|
|
104
104
|
try {
|
|
105
|
-
await ctx.context.internalAdapter.
|
|
105
|
+
await ctx.context.internalAdapter.deleteUserSessions(session.user.id);
|
|
106
106
|
} catch (error) {
|
|
107
107
|
ctx.context.logger.error("Failed to delete anonymous user sessions", error);
|
|
108
108
|
throw APIError.from("INTERNAL_SERVER_ERROR", ANONYMOUS_ERROR_CODES.FAILED_TO_DELETE_ANONYMOUS_USER_SESSIONS);
|
|
@@ -154,7 +154,7 @@ const anonymous = (options) => {
|
|
|
154
154
|
const newSessionIsAnonymous = Boolean(newSessionUser?.isAnonymous);
|
|
155
155
|
if (options?.disableDeleteAnonymousUser || isSameUser || newSessionIsAnonymous) return;
|
|
156
156
|
try {
|
|
157
|
-
await ctx.context.internalAdapter.
|
|
157
|
+
await ctx.context.internalAdapter.deleteUserSessions(session.user.id);
|
|
158
158
|
await ctx.context.internalAdapter.deleteUser(session.user.id);
|
|
159
159
|
} catch (error) {
|
|
160
160
|
ctx.context.logger.error("Failed to clean up anonymous user during post-link cleanup", {
|
|
@@ -585,7 +585,7 @@ const resetPasswordEmailOTP = (opts) => createAuthEndpoint("/email-otp/reset-pas
|
|
|
585
585
|
else await ctx.context.internalAdapter.updatePassword(user.user.id, passwordHash);
|
|
586
586
|
if (ctx.context.options.emailAndPassword?.onPasswordReset) await ctx.context.options.emailAndPassword.onPasswordReset({ user: user.user }, ctx.request);
|
|
587
587
|
if (!user.user.emailVerified) await ctx.context.internalAdapter.updateUser(user.user.id, { emailVerified: true });
|
|
588
|
-
if (ctx.context.options.emailAndPassword?.revokeSessionsOnPasswordReset) await ctx.context.internalAdapter.
|
|
588
|
+
if (ctx.context.options.emailAndPassword?.revokeSessionsOnPasswordReset) await ctx.context.internalAdapter.deleteUserSessions(user.user.id);
|
|
589
589
|
return ctx.json({ success: true });
|
|
590
590
|
});
|
|
591
591
|
const requestEmailChangeEmailOTPBodySchema = z.object({
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { isAPIError } from "../../utils/is-api-error.mjs";
|
|
2
2
|
import { setSessionCookie } from "../../cookies/index.mjs";
|
|
3
3
|
import { missingEmailLogMessage, redirectOnError } from "../../oauth2/errors.mjs";
|
|
4
|
-
import { generateState, parseState } from "../../oauth2/state.mjs";
|
|
5
4
|
import { setTokenUtil } from "../../oauth2/utils.mjs";
|
|
5
|
+
import { applyUpdateUserInfoOnLink, handleOAuthUserInfo } from "../../oauth2/link-account.mjs";
|
|
6
|
+
import { generateState, parseState } from "../../oauth2/state.mjs";
|
|
6
7
|
import { sessionMiddleware } from "../../api/routes/session.mjs";
|
|
7
|
-
import { handleOAuthUserInfo } from "../../oauth2/link-account.mjs";
|
|
8
8
|
import { HIDE_METADATA } from "../../utils/hide-metadata.mjs";
|
|
9
9
|
import { APIError as APIError$1 } from "../../api/index.mjs";
|
|
10
10
|
import { GENERIC_OAUTH_ERROR_CODES } from "./error-codes.mjs";
|
|
@@ -248,6 +248,7 @@ const oAuth2Callback = (options) => createAuthEndpoint("/oauth2/callback/:provid
|
|
|
248
248
|
refreshToken: await setTokenUtil(tokens.refreshToken, ctx.context),
|
|
249
249
|
idToken: tokens.idToken
|
|
250
250
|
})) redirectOnError(ctx, resolvedErrorURL, "unable_to_link_account");
|
|
251
|
+
await applyUpdateUserInfoOnLink(ctx, link.userId, userInfo);
|
|
251
252
|
let toRedirectTo;
|
|
252
253
|
try {
|
|
253
254
|
toRedirectTo = callbackURL.toString();
|
|
@@ -14,6 +14,7 @@ import { oidcProvider } from "../oidc-provider/index.mjs";
|
|
|
14
14
|
import { authorizeMCPOAuth } from "./authorize.mjs";
|
|
15
15
|
import { isProduction, logger } from "@better-auth/core/env";
|
|
16
16
|
import { safeJSONParse } from "@better-auth/core/utils/json";
|
|
17
|
+
import { isSafeUrlScheme } from "@better-auth/core/utils/url";
|
|
17
18
|
import { createAuthEndpoint, createAuthMiddleware } from "@better-auth/core/api";
|
|
18
19
|
import * as z from "zod";
|
|
19
20
|
import { base64 } from "@better-auth/utils/base64";
|
|
@@ -86,7 +87,7 @@ const getMCPProtectedResourceMetadata = (ctx, options) => {
|
|
|
86
87
|
};
|
|
87
88
|
};
|
|
88
89
|
const registerMcpClientBodySchema = z.object({
|
|
89
|
-
redirect_uris: z.array(z.string()),
|
|
90
|
+
redirect_uris: z.array(z.string().refine(isSafeUrlScheme, { message: "redirect_uri cannot use a javascript:, data:, or vbscript: scheme" })),
|
|
90
91
|
token_endpoint_auth_method: z.enum([
|
|
91
92
|
"none",
|
|
92
93
|
"client_secret_basic",
|
|
@@ -5,8 +5,8 @@ import { parseSetCookieHeader } from "../../cookies/cookie-utils.mjs";
|
|
|
5
5
|
import { symmetricDecrypt, symmetricEncrypt } from "../../crypto/index.mjs";
|
|
6
6
|
import { setSessionCookie } from "../../cookies/index.mjs";
|
|
7
7
|
import { redirectOnError } from "../../oauth2/errors.mjs";
|
|
8
|
-
import { parseGenericState } from "../../state.mjs";
|
|
9
8
|
import { handleOAuthUserInfo } from "../../oauth2/link-account.mjs";
|
|
9
|
+
import { parseGenericState } from "../../state.mjs";
|
|
10
10
|
import { PACKAGE_VERSION } from "../../version.mjs";
|
|
11
11
|
import { parseJSON } from "../../client/parser.mjs";
|
|
12
12
|
import { checkSkipProxy, resolveCurrentURL, stripTrailingSlash } from "./utils.mjs";
|
|
@@ -15,6 +15,7 @@ import { authorize } from "./authorize.mjs";
|
|
|
15
15
|
import { schema } from "./schema.mjs";
|
|
16
16
|
import { defaultClientSecretHasher } from "./utils.mjs";
|
|
17
17
|
import { getCurrentAuthContext } from "@better-auth/core/context";
|
|
18
|
+
import { isSafeUrlScheme } from "@better-auth/core/utils/url";
|
|
18
19
|
import { createAuthEndpoint, createAuthMiddleware } from "@better-auth/core/api";
|
|
19
20
|
import { deprecate } from "@better-auth/core/utils/deprecate";
|
|
20
21
|
import * as z from "zod";
|
|
@@ -101,7 +102,7 @@ const oAuthConsentBodySchema = z.object({
|
|
|
101
102
|
});
|
|
102
103
|
const oAuth2TokenBodySchema = z.record(z.any(), z.any());
|
|
103
104
|
const registerOAuthApplicationBodySchema = z.object({
|
|
104
|
-
redirect_uris: z.array(z.string()).meta({ description: "A list of redirect URIs. Eg: [\"https://client.example.com/callback\"]" }),
|
|
105
|
+
redirect_uris: z.array(z.string().refine(isSafeUrlScheme, { message: "redirect_uri cannot use a javascript:, data:, or vbscript: scheme" })).meta({ description: "A list of redirect URIs. Eg: [\"https://client.example.com/callback\"]" }),
|
|
105
106
|
token_endpoint_auth_method: z.enum([
|
|
106
107
|
"none",
|
|
107
108
|
"client_secret_basic",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { PACKAGE_VERSION } from "../../version.mjs";
|
|
2
|
+
import { isSafeUrlScheme } from "@better-auth/core/utils/url";
|
|
2
3
|
//#region src/plugins/one-tap/client.ts
|
|
3
4
|
let isRequestInProgress = false;
|
|
4
5
|
function isFedCMSupported() {
|
|
@@ -49,7 +50,10 @@ const oneTapClient = (options) => {
|
|
|
49
50
|
...opts?.fetchOptions,
|
|
50
51
|
...fetchOptions
|
|
51
52
|
});
|
|
52
|
-
if (!opts?.fetchOptions && !fetchOptions || opts?.callbackURL)
|
|
53
|
+
if (!opts?.fetchOptions && !fetchOptions || opts?.callbackURL) {
|
|
54
|
+
const target = opts?.callbackURL ?? "/";
|
|
55
|
+
if (isSafeUrlScheme(target)) window.location.href = target;
|
|
56
|
+
}
|
|
53
57
|
}
|
|
54
58
|
const { autoSelect, cancelOnTapOutside, context } = opts ?? {};
|
|
55
59
|
const contextValue = context ?? options.context ?? "signin";
|
|
@@ -82,7 +86,10 @@ const oneTapClient = (options) => {
|
|
|
82
86
|
...opts?.fetchOptions,
|
|
83
87
|
...fetchOptions
|
|
84
88
|
});
|
|
85
|
-
if (!opts?.fetchOptions && !fetchOptions || opts?.callbackURL)
|
|
89
|
+
if (!opts?.fetchOptions && !fetchOptions || opts?.callbackURL) {
|
|
90
|
+
const target = opts?.callbackURL ?? "/";
|
|
91
|
+
if (isSafeUrlScheme(target)) window.location.href = target;
|
|
92
|
+
}
|
|
86
93
|
}
|
|
87
94
|
const { autoSelect, cancelOnTapOutside, context } = opts ?? {};
|
|
88
95
|
const contextValue = context ?? options.context ?? "signin";
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { parseUserOutput } from "../../db/schema.mjs";
|
|
2
2
|
import { setSessionCookie } from "../../cookies/index.mjs";
|
|
3
|
+
import { handleOAuthUserInfo } from "../../oauth2/link-account.mjs";
|
|
3
4
|
import { APIError } from "../../api/index.mjs";
|
|
4
5
|
import { PACKAGE_VERSION } from "../../version.mjs";
|
|
5
6
|
import { toBoolean } from "../../utils/boolean.mjs";
|
|
@@ -47,51 +48,27 @@ const oneTap = (options) => ({
|
|
|
47
48
|
}
|
|
48
49
|
const { email: rawEmail, email_verified, name, picture, sub } = payload;
|
|
49
50
|
if (!rawEmail) return ctx.json({ error: "Email not available in token" });
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const newUser = await ctx.context.internalAdapter.createOAuthUser({
|
|
55
|
-
email,
|
|
51
|
+
const result = await handleOAuthUserInfo(ctx, {
|
|
52
|
+
userInfo: {
|
|
53
|
+
id: sub,
|
|
54
|
+
email: rawEmail.toLowerCase(),
|
|
56
55
|
emailVerified: typeof email_verified === "boolean" ? email_verified : toBoolean(email_verified),
|
|
57
|
-
name,
|
|
56
|
+
name: name ?? "",
|
|
58
57
|
image: picture
|
|
59
|
-
},
|
|
60
|
-
|
|
61
|
-
accountId: sub
|
|
62
|
-
});
|
|
63
|
-
if (!newUser) throw new APIError("INTERNAL_SERVER_ERROR", { message: "Could not create user" });
|
|
64
|
-
const session = await ctx.context.internalAdapter.createSession(newUser.user.id);
|
|
65
|
-
await setSessionCookie(ctx, {
|
|
66
|
-
user: newUser.user,
|
|
67
|
-
session
|
|
68
|
-
});
|
|
69
|
-
return ctx.json({
|
|
70
|
-
token: session.token,
|
|
71
|
-
user: parseUserOutput(ctx.context.options, newUser.user)
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
if (!await ctx.context.internalAdapter.findAccount(sub)) {
|
|
75
|
-
const accountLinking = ctx.context.options.account?.accountLinking;
|
|
76
|
-
const providerEmailVerified = typeof email_verified === "boolean" ? email_verified : toBoolean(email_verified);
|
|
77
|
-
const requireLocalEmailVerified = accountLinking?.requireLocalEmailVerified ?? true;
|
|
78
|
-
if (accountLinking?.enabled !== false && accountLinking?.disableImplicitLinking !== true && (!requireLocalEmailVerified || user.user.emailVerified) && (ctx.context.trustedProviders.includes("google") || providerEmailVerified)) await ctx.context.internalAdapter.linkAccount({
|
|
79
|
-
userId: user.user.id,
|
|
58
|
+
},
|
|
59
|
+
account: {
|
|
80
60
|
providerId: "google",
|
|
81
61
|
accountId: sub,
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
const session = await ctx.context.internalAdapter.createSession(user.user.id);
|
|
88
|
-
await setSessionCookie(ctx, {
|
|
89
|
-
user: user.user,
|
|
90
|
-
session
|
|
62
|
+
idToken,
|
|
63
|
+
scope: "openid,profile,email"
|
|
64
|
+
},
|
|
65
|
+
disableSignUp: options?.disableSignup
|
|
91
66
|
});
|
|
67
|
+
if (result.error) throw new APIError("UNAUTHORIZED", { message: result.error });
|
|
68
|
+
await setSessionCookie(ctx, result.data);
|
|
92
69
|
return ctx.json({
|
|
93
|
-
token: session.token,
|
|
94
|
-
user: parseUserOutput(ctx.context.options,
|
|
70
|
+
token: result.data.session.token,
|
|
71
|
+
user: parseUserOutput(ctx.context.options, result.data.user)
|
|
95
72
|
});
|
|
96
73
|
}) },
|
|
97
74
|
options
|
|
@@ -550,9 +550,11 @@ const getOrgAdapter = (context, options) => {
|
|
|
550
550
|
createInvitation: async ({ invitation, user }) => {
|
|
551
551
|
const adapter = await getCurrentAdapter(baseAdapter);
|
|
552
552
|
const expiresAt = getDate(options?.invitationExpiresIn || 3600 * 48, "sec");
|
|
553
|
+
const invitationId = context.generateId({ model: "invitation" });
|
|
553
554
|
return await adapter.create({
|
|
554
555
|
model: "invitation",
|
|
555
556
|
data: {
|
|
557
|
+
...invitationId !== false ? { id: invitationId } : {},
|
|
556
558
|
status: "pending",
|
|
557
559
|
expiresAt,
|
|
558
560
|
createdAt: /* @__PURE__ */ new Date(),
|
|
@@ -14,7 +14,7 @@ declare const createOrgRole: <O extends OrganizationOptions>(options: O) => bett
|
|
|
14
14
|
role: z.ZodString;
|
|
15
15
|
permission: z.ZodRecord<z.ZodString, z.ZodArray<z.ZodString>>;
|
|
16
16
|
additionalFields: z.ZodOptional<z.ZodObject<{
|
|
17
|
-
[x: string]: z.
|
|
17
|
+
[x: string]: z.ZodAny;
|
|
18
18
|
}, z.core.$strip>>;
|
|
19
19
|
}, z.core.$strip>;
|
|
20
20
|
metadata: {
|
|
@@ -19,6 +19,20 @@ const baseInvitationSchema = z.object({
|
|
|
19
19
|
resend: z.boolean().meta({ description: "Resend the invitation email, if the user is already invited. Eg: true" }).optional(),
|
|
20
20
|
teamId: z.union([z.string().meta({ description: "The team ID to invite the user to" }).optional(), z.array(z.string()).meta({ description: "The team IDs to invite the user to" }).optional()])
|
|
21
21
|
});
|
|
22
|
+
const getAdvancedGenerateId = (advancedOptions) => {
|
|
23
|
+
if (typeof advancedOptions !== "object" || advancedOptions === null) return;
|
|
24
|
+
const generateId = advancedOptions.generateId;
|
|
25
|
+
if (typeof generateId !== "function") return;
|
|
26
|
+
return generateId;
|
|
27
|
+
};
|
|
28
|
+
const hasBuiltInOpaqueInvitationIdGeneration = ({ advancedGenerateId, databaseGenerateId }) => advancedGenerateId === void 0 && (databaseGenerateId === void 0 || databaseGenerateId === "uuid");
|
|
29
|
+
const shouldRequireVerifiedEmailForInvitationIdAction = ({ organizationOptions, advancedGenerateId, databaseGenerateId }) => {
|
|
30
|
+
if (organizationOptions.requireEmailVerificationOnInvitation !== void 0) return organizationOptions.requireEmailVerificationOnInvitation;
|
|
31
|
+
return !hasBuiltInOpaqueInvitationIdGeneration({
|
|
32
|
+
advancedGenerateId,
|
|
33
|
+
databaseGenerateId
|
|
34
|
+
});
|
|
35
|
+
};
|
|
22
36
|
const createInvitation = (option) => {
|
|
23
37
|
const additionalFieldsSchema = toZodSchema({
|
|
24
38
|
fields: option?.schema?.invitation?.additionalFields || {},
|
|
@@ -247,7 +261,11 @@ const acceptInvitation = (options) => createAuthEndpoint("/organization/accept-i
|
|
|
247
261
|
const invitation = await adapter.findInvitationById(ctx.body.invitationId);
|
|
248
262
|
if (!invitation || invitation.expiresAt < /* @__PURE__ */ new Date() || invitation.status !== "pending") throw APIError.from("BAD_REQUEST", ORGANIZATION_ERROR_CODES.INVITATION_NOT_FOUND);
|
|
249
263
|
if (invitation.email.toLowerCase() !== session.user.email.toLowerCase()) throw APIError.from("FORBIDDEN", ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION);
|
|
250
|
-
if ((
|
|
264
|
+
if (shouldRequireVerifiedEmailForInvitationIdAction({
|
|
265
|
+
organizationOptions: ctx.context.orgOptions,
|
|
266
|
+
advancedGenerateId: getAdvancedGenerateId(ctx.context.options.advanced),
|
|
267
|
+
databaseGenerateId: ctx.context.options.advanced?.database?.generateId
|
|
268
|
+
}) && !session.user.emailVerified) throw APIError.from("FORBIDDEN", ORGANIZATION_ERROR_CODES.EMAIL_VERIFICATION_REQUIRED_BEFORE_ACCEPTING_OR_REJECTING_INVITATION);
|
|
251
269
|
const membershipLimit = ctx.context.orgOptions?.membershipLimit || 100;
|
|
252
270
|
const membersCount = await adapter.countMembers({ organizationId: invitation.organizationId });
|
|
253
271
|
const organization = await adapter.findOrganizationById(invitation.organizationId);
|
|
@@ -336,7 +354,11 @@ const rejectInvitation = (options) => createAuthEndpoint("/organization/reject-i
|
|
|
336
354
|
code: "INVITATION_NOT_FOUND"
|
|
337
355
|
});
|
|
338
356
|
if (invitation.email.toLowerCase() !== session.user.email.toLowerCase()) throw APIError.from("FORBIDDEN", ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION);
|
|
339
|
-
if ((
|
|
357
|
+
if (shouldRequireVerifiedEmailForInvitationIdAction({
|
|
358
|
+
organizationOptions: ctx.context.orgOptions,
|
|
359
|
+
advancedGenerateId: getAdvancedGenerateId(ctx.context.options.advanced),
|
|
360
|
+
databaseGenerateId: ctx.context.options.advanced?.database?.generateId
|
|
361
|
+
}) && !session.user.emailVerified) throw APIError.from("FORBIDDEN", ORGANIZATION_ERROR_CODES.EMAIL_VERIFICATION_REQUIRED_BEFORE_ACCEPTING_OR_REJECTING_INVITATION);
|
|
340
362
|
const organization = await adapter.findOrganizationById(invitation.organizationId);
|
|
341
363
|
if (!organization) throw APIError.from("BAD_REQUEST", ORGANIZATION_ERROR_CODES.ORGANIZATION_NOT_FOUND);
|
|
342
364
|
if (options?.organizationHooks?.beforeRejectInvitation) await options?.organizationHooks.beforeRejectInvitation({
|
|
@@ -455,7 +477,11 @@ const getInvitation = (options) => createAuthEndpoint("/organization/get-invitat
|
|
|
455
477
|
const invitation = await adapter.findInvitationById(ctx.query.id);
|
|
456
478
|
if (!invitation || invitation.status !== "pending" || invitation.expiresAt < /* @__PURE__ */ new Date()) throw APIError.fromStatus("BAD_REQUEST", { message: "Invitation not found!" });
|
|
457
479
|
if (invitation.email.toLowerCase() !== session.user.email.toLowerCase()) throw APIError.from("FORBIDDEN", ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION);
|
|
458
|
-
if ((
|
|
480
|
+
if (shouldRequireVerifiedEmailForInvitationIdAction({
|
|
481
|
+
organizationOptions: ctx.context.orgOptions,
|
|
482
|
+
advancedGenerateId: getAdvancedGenerateId(ctx.context.options.advanced),
|
|
483
|
+
databaseGenerateId: ctx.context.options.advanced?.database?.generateId
|
|
484
|
+
}) && !session.user.emailVerified) throw APIError.from("FORBIDDEN", ORGANIZATION_ERROR_CODES.EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION);
|
|
459
485
|
const organization = await adapter.findOrganizationById(invitation.organizationId);
|
|
460
486
|
if (!organization) throw APIError.from("BAD_REQUEST", ORGANIZATION_ERROR_CODES.ORGANIZATION_NOT_FOUND);
|
|
461
487
|
const member = await adapter.findMemberByOrgId({
|
|
@@ -541,7 +567,7 @@ const listUserInvitations = (options) => createAuthEndpoint("/organization/list-
|
|
|
541
567
|
}, async (ctx) => {
|
|
542
568
|
const session = await getSessionFromCtx(ctx);
|
|
543
569
|
if (ctx.request && ctx.query?.email) throw APIError.fromStatus("BAD_REQUEST", { message: "User email cannot be passed for client side API calls." });
|
|
544
|
-
if (session &&
|
|
570
|
+
if (session && !session.user.emailVerified) throw APIError.from("FORBIDDEN", ORGANIZATION_ERROR_CODES.EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION);
|
|
545
571
|
const userEmail = session?.user.email || ctx.query?.email;
|
|
546
572
|
if (!userEmail) throw APIError.fromStatus("BAD_REQUEST", { message: "Missing session headers, or email query parameter." });
|
|
547
573
|
const pendingInvitations = (await getOrgAdapter(ctx.context, options).listUserInvitations(userEmail)).filter((inv) => inv.status === "pending");
|
|
@@ -15,7 +15,7 @@ declare const createOrganization: <O extends OrganizationOptions>(options?: O |
|
|
|
15
15
|
name: z.ZodString;
|
|
16
16
|
slug: z.ZodString;
|
|
17
17
|
userId: z.ZodOptional<z.ZodCoercedString<unknown>>;
|
|
18
|
-
logo: z.ZodOptional<z.ZodString
|
|
18
|
+
logo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
19
19
|
metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodAny>>;
|
|
20
20
|
keepCurrentActiveOrganization: z.ZodOptional<z.ZodBoolean>;
|
|
21
21
|
}, z.core.$strip>;
|
|
@@ -55,7 +55,7 @@ declare const createOrganization: <O extends OrganizationOptions>(options?: O |
|
|
|
55
55
|
name: string;
|
|
56
56
|
slug: string;
|
|
57
57
|
userId?: string | undefined;
|
|
58
|
-
logo?: string | undefined;
|
|
58
|
+
logo?: string | null | undefined;
|
|
59
59
|
metadata?: Record<string, any> | undefined;
|
|
60
60
|
keepCurrentActiveOrganization?: boolean | undefined;
|
|
61
61
|
};
|
|
@@ -165,7 +165,7 @@ declare const updateOrganization: <O extends OrganizationOptions>(options?: O |
|
|
|
165
165
|
data: z.ZodObject<{
|
|
166
166
|
name: z.ZodOptional<z.ZodOptional<z.ZodString>>;
|
|
167
167
|
slug: z.ZodOptional<z.ZodOptional<z.ZodString>>;
|
|
168
|
-
logo: z.ZodOptional<z.ZodOptional<z.ZodString
|
|
168
|
+
logo: z.ZodOptional<z.ZodOptional<z.ZodNullable<z.ZodString>>>;
|
|
169
169
|
metadata: z.ZodOptional<z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodAny>>>;
|
|
170
170
|
}, z.core.$strip>;
|
|
171
171
|
organizationId: z.ZodOptional<z.ZodString>;
|
|
@@ -207,7 +207,7 @@ declare const updateOrganization: <O extends OrganizationOptions>(options?: O |
|
|
|
207
207
|
data: {
|
|
208
208
|
name?: string | undefined;
|
|
209
209
|
slug?: string | undefined;
|
|
210
|
-
logo?: string | undefined;
|
|
210
|
+
logo?: string | null | undefined;
|
|
211
211
|
metadata?: Record<string, any> | undefined;
|
|
212
212
|
} & Partial<InferAdditionalFieldsFromPluginOptions<"organization", O>>;
|
|
213
213
|
organizationId?: string | undefined;
|
|
@@ -13,7 +13,7 @@ const baseOrganizationSchema = z.object({
|
|
|
13
13
|
name: z.string().min(1).meta({ description: "The name of the organization" }),
|
|
14
14
|
slug: z.string().min(1).meta({ description: "The slug of the organization" }),
|
|
15
15
|
userId: z.coerce.string().meta({ description: "The user id of the organization creator. If not provided, the current user will be used. Should only be used by admins or when called by the server. server-only. Eg: \"user-id\"" }).optional(),
|
|
16
|
-
logo: z.string().meta({ description: "The logo of the organization" }).
|
|
16
|
+
logo: z.string().meta({ description: "The logo of the organization" }).nullish(),
|
|
17
17
|
metadata: z.record(z.string(), z.any()).meta({ description: "The metadata of the organization" }).optional(),
|
|
18
18
|
keepCurrentActiveOrganization: z.boolean().meta({ description: "Whether to keep the current active organization active after creating a new one. Eg: true" }).optional()
|
|
19
19
|
});
|
|
@@ -160,7 +160,7 @@ const checkOrganizationSlug = (options) => createAuthEndpoint("/organization/che
|
|
|
160
160
|
const baseUpdateOrganizationSchema = z.object({
|
|
161
161
|
name: z.string().min(1).meta({ description: "The name of the organization" }).optional(),
|
|
162
162
|
slug: z.string().min(1).meta({ description: "The slug of the organization" }).optional(),
|
|
163
|
-
logo: z.string().meta({ description: "The logo of the organization" }).
|
|
163
|
+
logo: z.string().meta({ description: "The logo of the organization" }).nullish(),
|
|
164
164
|
metadata: z.record(z.string(), z.any()).meta({ description: "The metadata of the organization" }).optional()
|
|
165
165
|
});
|
|
166
166
|
const updateOrganization = (options) => {
|
|
@@ -165,19 +165,26 @@ interface OrganizationOptions {
|
|
|
165
165
|
*/
|
|
166
166
|
cancelPendingInvitationsOnReInvite?: boolean | undefined;
|
|
167
167
|
/**
|
|
168
|
-
* Require email verification
|
|
169
|
-
* calls (accept, reject, get
|
|
170
|
-
* accounts registered against a victim's email cannot accept, read, or
|
|
171
|
-
* enumerate invitations targeted at that email. Server-side
|
|
172
|
-
* `listUserInvitations` calls without a session (caller passes
|
|
173
|
-
* `ctx.query.email`) continue to bypass the gate because the caller is
|
|
174
|
-
* trusted. Set to `false` for backward compatibility on apps that do not
|
|
175
|
-
* require email verification; understand the takeover risk before doing so.
|
|
168
|
+
* Require email verification before session-authenticated recipient
|
|
169
|
+
* invitation calls that carry an invitation ID (accept, reject, get).
|
|
176
170
|
*
|
|
177
|
-
*
|
|
171
|
+
* When unset, Better Auth preserves the normal emailed-invitation flow for
|
|
172
|
+
* built-in opaque invitation IDs, including the default generator and
|
|
173
|
+
* `advanced.database.generateId: "uuid"`. It requires verification for
|
|
174
|
+
* externally controlled or predictable invitation IDs, such as
|
|
175
|
+
* `advanced.database.generateId: "serial"` / `false` or custom ID
|
|
176
|
+
* generation.
|
|
177
|
+
*
|
|
178
|
+
* Set this option to `true` when invitation IDs may be visible outside the
|
|
179
|
+
* invited user's mailbox, when organization invitation lists are exposed to
|
|
180
|
+
* members, or when verified email should be the ownership proof for by-ID
|
|
181
|
+
* invitation actions. Client-side `listUserInvitations` calls always require
|
|
182
|
+
* a verified session email because they enumerate invitation IDs from
|
|
183
|
+
* `session.user.email`. Server-side `listUserInvitations` calls without a
|
|
184
|
+
* session (caller passes `ctx.query.email`) continue to bypass the gate
|
|
185
|
+
* because the caller is trusted.
|
|
178
186
|
*
|
|
179
|
-
* @
|
|
180
|
-
* become unconditional. Plan to verify emails before invitation acceptance.
|
|
187
|
+
* @default undefined
|
|
181
188
|
*/
|
|
182
189
|
requireEmailVerificationOnInvitation?: boolean | undefined;
|
|
183
190
|
/**
|
|
@@ -317,7 +324,7 @@ interface OrganizationOptions {
|
|
|
317
324
|
organization: {
|
|
318
325
|
name?: string;
|
|
319
326
|
slug?: string;
|
|
320
|
-
logo?: string;
|
|
327
|
+
logo?: string | null;
|
|
321
328
|
metadata?: Record<string, any>;
|
|
322
329
|
[key: string]: any;
|
|
323
330
|
};
|
|
@@ -348,7 +355,7 @@ interface OrganizationOptions {
|
|
|
348
355
|
organization: {
|
|
349
356
|
name?: string;
|
|
350
357
|
slug?: string;
|
|
351
|
-
logo?: string;
|
|
358
|
+
logo?: string | null;
|
|
352
359
|
metadata?: Record<string, any>;
|
|
353
360
|
[key: string]: any;
|
|
354
361
|
};
|
|
@@ -358,7 +365,7 @@ interface OrganizationOptions {
|
|
|
358
365
|
data: {
|
|
359
366
|
name?: string;
|
|
360
367
|
slug?: string;
|
|
361
|
-
logo?: string;
|
|
368
|
+
logo?: string | null;
|
|
362
369
|
metadata?: Record<string, any>;
|
|
363
370
|
[key: string]: any;
|
|
364
371
|
};
|
|
@@ -470,7 +470,7 @@ const resetPasswordPhoneNumber = (opts) => createAuthEndpoint("/phone-number/res
|
|
|
470
470
|
else await ctx.context.internalAdapter.updatePassword(user.id, hashedPassword);
|
|
471
471
|
await ctx.context.internalAdapter.deleteVerificationByIdentifier(phoneResetIdentifier);
|
|
472
472
|
if (ctx.context.options.emailAndPassword?.onPasswordReset) await ctx.context.options.emailAndPassword.onPasswordReset({ user }, ctx.request);
|
|
473
|
-
if (ctx.context.options.emailAndPassword?.revokeSessionsOnPasswordReset) await ctx.context.internalAdapter.
|
|
473
|
+
if (ctx.context.options.emailAndPassword?.revokeSessionsOnPasswordReset) await ctx.context.internalAdapter.deleteUserSessions(user.id);
|
|
474
474
|
return ctx.json({ status: true });
|
|
475
475
|
});
|
|
476
476
|
function generateOTP(size) {
|
|
@@ -264,9 +264,10 @@ declare const backupCode2fa: (opts: BackupCodeOptions) => {
|
|
|
264
264
|
backupCodes: string[];
|
|
265
265
|
}>;
|
|
266
266
|
/**
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
267
|
+
* A server-only function that returns a user's decrypted two-factor
|
|
268
|
+
* backup codes. It is not exposed over HTTP and has no client method;
|
|
269
|
+
* call it from trusted server code with a `userId` taken from an
|
|
270
|
+
* authenticated session.
|
|
270
271
|
*
|
|
271
272
|
* ### API Methods
|
|
272
273
|
*
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { PACKAGE_VERSION } from "../../version.mjs";
|
|
2
2
|
import { TWO_FACTOR_ERROR_CODES } from "./error-code.mjs";
|
|
3
|
+
import { isSafeUrlScheme } from "@better-auth/core/utils/url";
|
|
3
4
|
//#region src/plugins/two-factor/client.ts
|
|
4
5
|
const twoFactorClient = (options) => {
|
|
5
6
|
return {
|
|
@@ -29,7 +30,7 @@ const twoFactorClient = (options) => {
|
|
|
29
30
|
await options.onTwoFactorRedirect({ twoFactorMethods: context.data.twoFactorMethods });
|
|
30
31
|
return;
|
|
31
32
|
}
|
|
32
|
-
if (options?.twoFactorPage && typeof window !== "undefined") window.location.href = options.twoFactorPage;
|
|
33
|
+
if (options?.twoFactorPage && typeof window !== "undefined" && isSafeUrlScheme(options.twoFactorPage)) window.location.href = options.twoFactorPage;
|
|
33
34
|
}
|
|
34
35
|
} }
|
|
35
36
|
}],
|