better-auth 1.6.16 → 1.6.17
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 +2 -2
- package/dist/api/index.mjs +3 -4
- package/dist/api/middlewares/origin-check.mjs +5 -1
- package/dist/api/rate-limiter/index.mjs +259 -73
- package/dist/api/routes/account.mjs +22 -7
- package/dist/api/routes/callback.mjs +2 -2
- package/dist/api/routes/index.d.mts +1 -1
- package/dist/api/routes/password.mjs +3 -4
- package/dist/api/routes/session.d.mts +12 -1
- package/dist/api/routes/session.mjs +13 -1
- package/dist/api/routes/sign-in.mjs +5 -5
- package/dist/api/routes/sign-up.mjs +2 -2
- package/dist/api/routes/update-session.mjs +2 -3
- package/dist/api/routes/update-user.mjs +10 -12
- package/dist/auth/base.mjs +11 -7
- package/dist/client/equality.d.mts +19 -0
- package/dist/client/equality.mjs +42 -0
- package/dist/client/index.d.mts +5 -4
- package/dist/client/index.mjs +2 -1
- package/dist/client/path-to-object.d.mts +5 -2
- package/dist/client/plugins/index.d.mts +4 -1
- package/dist/client/plugins/index.mjs +4 -1
- package/dist/client/query.d.mts +4 -3
- package/dist/client/query.mjs +27 -17
- package/dist/client/session-atom.mjs +129 -4
- package/dist/client/session-refresh.d.mts +3 -18
- package/dist/client/session-refresh.mjs +38 -49
- package/dist/client/types.d.mts +2 -2
- package/dist/context/create-context.mjs +2 -1
- package/dist/context/store-capabilities.mjs +12 -0
- package/dist/cookies/index.mjs +25 -2
- package/dist/db/internal-adapter.mjs +51 -0
- package/dist/package.mjs +1 -1
- package/dist/plugins/access/access.mjs +49 -19
- package/dist/plugins/admin/routes.mjs +10 -3
- package/dist/plugins/captcha/constants.mjs +8 -1
- package/dist/plugins/captcha/index.mjs +8 -2
- package/dist/plugins/captcha/types.d.mts +21 -0
- package/dist/plugins/captcha/verify-handlers/captchafox.mjs +2 -0
- package/dist/plugins/captcha/verify-handlers/cloudflare-turnstile.mjs +7 -2
- package/dist/plugins/captcha/verify-handlers/google-recaptcha.mjs +7 -2
- package/dist/plugins/captcha/verify-handlers/h-captcha.mjs +2 -0
- package/dist/plugins/device-authorization/routes.mjs +16 -9
- package/dist/plugins/email-otp/routes.mjs +22 -52
- package/dist/plugins/generic-oauth/index.mjs +7 -2
- package/dist/plugins/generic-oauth/routes.mjs +16 -12
- package/dist/plugins/haveibeenpwned/index.d.mts +1 -1
- package/dist/plugins/haveibeenpwned/index.mjs +5 -1
- package/dist/plugins/index.d.mts +5 -1
- package/dist/plugins/index.mjs +4 -1
- package/dist/plugins/jwt/index.mjs +2 -2
- package/dist/plugins/mcp/client/index.mjs +1 -0
- package/dist/plugins/mcp/index.mjs +8 -0
- package/dist/plugins/multi-session/index.mjs +7 -5
- package/dist/plugins/oauth-popup/client.d.mts +82 -0
- package/dist/plugins/oauth-popup/client.mjs +203 -0
- package/dist/plugins/oauth-popup/constants.d.mts +11 -0
- package/dist/plugins/oauth-popup/constants.mjs +11 -0
- package/dist/plugins/oauth-popup/error-codes.d.mts +11 -0
- package/dist/plugins/oauth-popup/error-codes.mjs +10 -0
- package/dist/plugins/oauth-popup/index.d.mts +67 -0
- package/dist/plugins/oauth-popup/index.mjs +227 -0
- package/dist/plugins/oauth-popup/types.d.mts +30 -0
- package/dist/plugins/oauth-proxy/index.mjs +2 -2
- package/dist/plugins/oauth-proxy/utils.mjs +16 -2
- package/dist/plugins/oidc-provider/index.mjs +10 -0
- package/dist/plugins/one-tap/client.mjs +12 -6
- package/dist/plugins/one-tap/index.d.mts +1 -0
- package/dist/plugins/one-tap/index.mjs +9 -5
- package/dist/plugins/one-time-token/index.mjs +1 -3
- package/dist/plugins/open-api/generator.mjs +7 -4
- package/dist/plugins/organization/adapter.d.mts +29 -1
- package/dist/plugins/organization/adapter.mjs +66 -6
- package/dist/plugins/organization/routes/crud-invites.mjs +49 -34
- package/dist/plugins/organization/routes/crud-members.mjs +42 -6
- package/dist/plugins/organization/routes/crud-team.mjs +36 -3
- package/dist/plugins/phone-number/routes.mjs +41 -36
- package/dist/plugins/siwe/index.mjs +2 -3
- package/dist/plugins/two-factor/backup-codes/index.mjs +1 -1
- package/dist/plugins/two-factor/otp/index.mjs +11 -13
- package/dist/plugins/two-factor/totp/index.mjs +1 -1
- package/dist/plugins/two-factor/verify-two-factor.mjs +6 -2
- package/dist/plugins/username/index.mjs +6 -6
- package/package.json +9 -9
|
@@ -8,7 +8,10 @@ import { createAuthEndpoint } from "@better-auth/core/api";
|
|
|
8
8
|
import * as z from "zod";
|
|
9
9
|
import { createRemoteJWKSet, jwtVerify } from "jose";
|
|
10
10
|
//#region src/plugins/one-tap/index.ts
|
|
11
|
-
const oneTapCallbackBodySchema = z.object({
|
|
11
|
+
const oneTapCallbackBodySchema = z.object({
|
|
12
|
+
idToken: z.string().meta({ description: "Google ID token, which the client obtains from the One Tap API" }),
|
|
13
|
+
callbackURL: z.string().meta({ description: "URL to redirect to after a successful sign-in" }).optional()
|
|
14
|
+
});
|
|
12
15
|
const oneTap = (options) => ({
|
|
13
16
|
id: "one-tap",
|
|
14
17
|
version: PACKAGE_VERSION,
|
|
@@ -34,13 +37,14 @@ const oneTap = (options) => ({
|
|
|
34
37
|
} }
|
|
35
38
|
}, async (ctx) => {
|
|
36
39
|
const { idToken } = ctx.body;
|
|
40
|
+
const googleProvider = typeof ctx.context.options.socialProviders?.google === "function" ? await ctx.context.options.socialProviders?.google() : ctx.context.options.socialProviders?.google;
|
|
41
|
+
const audience = options?.clientId || googleProvider?.clientId;
|
|
42
|
+
if (!audience || Array.isArray(audience) && audience.length === 0) throw new APIError("BAD_REQUEST", { message: "Google client ID is required for One Tap. Set it on the oneTap plugin (clientId) or on socialProviders.google." });
|
|
37
43
|
let payload;
|
|
38
44
|
try {
|
|
39
|
-
const
|
|
40
|
-
const googleProvider = typeof ctx.context.options.socialProviders?.google === "function" ? await ctx.context.options.socialProviders?.google() : ctx.context.options.socialProviders?.google;
|
|
41
|
-
const { payload: verifiedPayload } = await jwtVerify(idToken, JWKS, {
|
|
45
|
+
const { payload: verifiedPayload } = await jwtVerify(idToken, createRemoteJWKSet(new URL("https://www.googleapis.com/oauth2/v3/certs")), {
|
|
42
46
|
issuer: ["https://accounts.google.com", "accounts.google.com"],
|
|
43
|
-
audience
|
|
47
|
+
audience
|
|
44
48
|
});
|
|
45
49
|
payload = verifiedPayload;
|
|
46
50
|
} catch {
|
|
@@ -47,10 +47,8 @@ const oneTimeToken = (options) => {
|
|
|
47
47
|
}, async (c) => {
|
|
48
48
|
const { token } = c.body;
|
|
49
49
|
const storedToken = await storeToken(c, token);
|
|
50
|
-
const verificationValue = await c.context.internalAdapter.
|
|
50
|
+
const verificationValue = await c.context.internalAdapter.consumeVerificationValue(`one-time-token:${storedToken}`);
|
|
51
51
|
if (!verificationValue) throw c.error("BAD_REQUEST", { message: "Invalid token" });
|
|
52
|
-
await c.context.internalAdapter.deleteVerificationByIdentifier(`one-time-token:${storedToken}`);
|
|
53
|
-
if (verificationValue.expiresAt < /* @__PURE__ */ new Date()) throw c.error("BAD_REQUEST", { message: "Token expired" });
|
|
54
52
|
const session = await c.context.internalAdapter.findSession(verificationValue.value);
|
|
55
53
|
if (!session) throw c.error("BAD_REQUEST", { message: "Session not found" });
|
|
56
54
|
if (!opts?.disableSetSessionCookie) await setSessionCookie(c, session);
|
|
@@ -178,12 +178,15 @@ async function generator(ctx, options) {
|
|
|
178
178
|
const components = { schemas: { ...Object.entries(tables).reduce((acc, [key, value]) => {
|
|
179
179
|
const modelName = key.charAt(0).toUpperCase() + key.slice(1);
|
|
180
180
|
const fields = value.fields;
|
|
181
|
-
const required = [];
|
|
182
|
-
const properties = { id: {
|
|
181
|
+
const required = new Set(["id"]);
|
|
182
|
+
const properties = { id: {
|
|
183
|
+
type: "string",
|
|
184
|
+
readOnly: true
|
|
185
|
+
} };
|
|
183
186
|
Object.entries(fields).forEach(([fieldKey, fieldValue]) => {
|
|
184
187
|
if (!fieldValue) return;
|
|
185
188
|
properties[fieldKey] = getFieldSchema(fieldValue);
|
|
186
|
-
if (fieldValue.required && fieldValue.
|
|
189
|
+
if (fieldValue.required && fieldValue.returned !== false) required.add(fieldKey);
|
|
187
190
|
});
|
|
188
191
|
Object.entries(properties).forEach(([key, prop]) => {
|
|
189
192
|
const field = value.fields[key];
|
|
@@ -192,7 +195,7 @@ async function generator(ctx, options) {
|
|
|
192
195
|
acc[modelName] = {
|
|
193
196
|
type: "object",
|
|
194
197
|
properties,
|
|
195
|
-
required
|
|
198
|
+
required: Array.from(required)
|
|
196
199
|
};
|
|
197
200
|
return acc;
|
|
198
201
|
}, {}) } };
|
|
@@ -536,6 +536,28 @@ declare const getOrgAdapter: <O extends OrganizationOptions>(context: AuthContex
|
|
|
536
536
|
userId: string;
|
|
537
537
|
createdAt: Date;
|
|
538
538
|
}>;
|
|
539
|
+
/**
|
|
540
|
+
* Adds a user to a team only when the team is below its member limit,
|
|
541
|
+
* reading the count and creating the membership in one transaction.
|
|
542
|
+
* Returns the existing membership unchanged (no capacity charge) when the
|
|
543
|
+
* user already belongs to the team.
|
|
544
|
+
*
|
|
545
|
+
* FIXME(team-cap-race): the count-then-create is not atomic under READ
|
|
546
|
+
* COMMITTED, so two concurrent adds can both pass the count check and
|
|
547
|
+
* exceed maximumMembersPerTeam. A durable fix needs a unique constraint on
|
|
548
|
+
* teamMember(teamId, userId) or serializable isolation. Affects every
|
|
549
|
+
* caller (acceptInvitation, addMember, addTeamMember).
|
|
550
|
+
*/
|
|
551
|
+
addTeamMemberWithLimit: (data: {
|
|
552
|
+
teamId: string;
|
|
553
|
+
userId: string;
|
|
554
|
+
maximumMembersPerTeam: number;
|
|
555
|
+
}) => Promise<{
|
|
556
|
+
status: "added";
|
|
557
|
+
member: TeamMember;
|
|
558
|
+
} | {
|
|
559
|
+
status: "limitReached";
|
|
560
|
+
}>;
|
|
539
561
|
removeTeamMember: (data: {
|
|
540
562
|
teamId: string;
|
|
541
563
|
userId: string;
|
|
@@ -757,7 +779,13 @@ declare const getOrgAdapter: <O extends OrganizationOptions>(context: AuthContex
|
|
|
757
779
|
} ? FieldAttributeToObject<Field> : {}) extends infer T ? { [K in keyof T]: T[K] } : never)[]>;
|
|
758
780
|
updateInvitation: (data: {
|
|
759
781
|
invitationId: string;
|
|
760
|
-
status: "accepted" | "canceled" | "rejected";
|
|
782
|
+
status: "pending" | "accepted" | "canceled" | "rejected";
|
|
783
|
+
/**
|
|
784
|
+
* Only transition when the invitation is currently in this status. The
|
|
785
|
+
* guarded update is atomic, so a concurrent caller racing the same
|
|
786
|
+
* transition gets `null` instead of both proceeding.
|
|
787
|
+
*/
|
|
788
|
+
fromStatus?: "pending";
|
|
761
789
|
}) => Promise<((O["teams"] extends {
|
|
762
790
|
enabled: true;
|
|
763
791
|
} ? {
|
|
@@ -4,6 +4,23 @@ import { getCurrentAdapter, runWithTransaction } from "@better-auth/core/context
|
|
|
4
4
|
import { BetterAuthError } from "@better-auth/core/error";
|
|
5
5
|
import { filterOutputFields } from "@better-auth/core/utils/db";
|
|
6
6
|
//#region src/plugins/organization/adapter.ts
|
|
7
|
+
/**
|
|
8
|
+
* Resolves the configured per-team member cap to a concrete number for a given
|
|
9
|
+
* team-add. Returns `undefined` only when no cap is configured. Throws when the
|
|
10
|
+
* cap is a function but no session is available to evaluate it, so a sessionless
|
|
11
|
+
* server-side add fails closed instead of silently bypassing the limit.
|
|
12
|
+
*/
|
|
13
|
+
async function resolveMaximumMembersPerTeam(teams, context) {
|
|
14
|
+
const maximumMembersPerTeam = teams?.maximumMembersPerTeam;
|
|
15
|
+
if (maximumMembersPerTeam === void 0) return void 0;
|
|
16
|
+
if (typeof maximumMembersPerTeam === "number") return maximumMembersPerTeam;
|
|
17
|
+
if (!context.session) throw new BetterAuthError("`teams.maximumMembersPerTeam` is configured as a function but no session is available to evaluate it. Provide a session-bearing request or configure a numeric limit.");
|
|
18
|
+
return await maximumMembersPerTeam({
|
|
19
|
+
teamId: context.teamId,
|
|
20
|
+
session: context.session,
|
|
21
|
+
organizationId: context.organizationId
|
|
22
|
+
});
|
|
23
|
+
}
|
|
7
24
|
const getOrgAdapter = (context, options) => {
|
|
8
25
|
const baseAdapter = context.adapter;
|
|
9
26
|
const orgAdditionalFields = options?.schema?.organization?.additionalFields;
|
|
@@ -513,6 +530,43 @@ const getOrgAdapter = (context, options) => {
|
|
|
513
530
|
}
|
|
514
531
|
});
|
|
515
532
|
},
|
|
533
|
+
addTeamMemberWithLimit: async (data) => {
|
|
534
|
+
return runWithTransaction(baseAdapter, async () => {
|
|
535
|
+
const adapter = await getCurrentAdapter(baseAdapter);
|
|
536
|
+
const existing = await adapter.findOne({
|
|
537
|
+
model: "teamMember",
|
|
538
|
+
where: [{
|
|
539
|
+
field: "teamId",
|
|
540
|
+
value: data.teamId
|
|
541
|
+
}, {
|
|
542
|
+
field: "userId",
|
|
543
|
+
value: data.userId
|
|
544
|
+
}]
|
|
545
|
+
});
|
|
546
|
+
if (existing) return {
|
|
547
|
+
status: "added",
|
|
548
|
+
member: existing
|
|
549
|
+
};
|
|
550
|
+
if (await adapter.count({
|
|
551
|
+
model: "teamMember",
|
|
552
|
+
where: [{
|
|
553
|
+
field: "teamId",
|
|
554
|
+
value: data.teamId
|
|
555
|
+
}]
|
|
556
|
+
}) >= data.maximumMembersPerTeam) return { status: "limitReached" };
|
|
557
|
+
return {
|
|
558
|
+
status: "added",
|
|
559
|
+
member: await adapter.create({
|
|
560
|
+
model: "teamMember",
|
|
561
|
+
data: {
|
|
562
|
+
teamId: data.teamId,
|
|
563
|
+
userId: data.userId,
|
|
564
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
565
|
+
}
|
|
566
|
+
})
|
|
567
|
+
};
|
|
568
|
+
});
|
|
569
|
+
},
|
|
516
570
|
removeTeamMember: async (data) => {
|
|
517
571
|
await (await getCurrentAdapter(baseAdapter)).deleteMany({
|
|
518
572
|
model: "teamMember",
|
|
@@ -615,16 +669,22 @@ const getOrgAdapter = (context, options) => {
|
|
|
615
669
|
});
|
|
616
670
|
},
|
|
617
671
|
updateInvitation: async (data) => {
|
|
618
|
-
|
|
672
|
+
const adapter = await getCurrentAdapter(baseAdapter);
|
|
673
|
+
const where = [{
|
|
674
|
+
field: "id",
|
|
675
|
+
value: data.invitationId
|
|
676
|
+
}];
|
|
677
|
+
if (data.fromStatus) where.push({
|
|
678
|
+
field: "status",
|
|
679
|
+
value: data.fromStatus
|
|
680
|
+
});
|
|
681
|
+
return await adapter.update({
|
|
619
682
|
model: "invitation",
|
|
620
|
-
where
|
|
621
|
-
field: "id",
|
|
622
|
-
value: data.invitationId
|
|
623
|
-
}],
|
|
683
|
+
where,
|
|
624
684
|
update: { status: data.status }
|
|
625
685
|
});
|
|
626
686
|
}
|
|
627
687
|
};
|
|
628
688
|
};
|
|
629
689
|
//#endregion
|
|
630
|
-
export { getOrgAdapter };
|
|
690
|
+
export { getOrgAdapter, resolveMaximumMembersPerTeam };
|
|
@@ -4,10 +4,11 @@ import { toZodSchema } from "../../../db/to-zod.mjs";
|
|
|
4
4
|
import { getSessionFromCtx } from "../../../api/routes/session.mjs";
|
|
5
5
|
import { defaultRoles } from "../access/statement.mjs";
|
|
6
6
|
import { ORGANIZATION_ERROR_CODES } from "../error-codes.mjs";
|
|
7
|
-
import { getOrgAdapter } from "../adapter.mjs";
|
|
7
|
+
import { getOrgAdapter, resolveMaximumMembersPerTeam } from "../adapter.mjs";
|
|
8
8
|
import { orgMiddleware, orgSessionMiddleware } from "../call.mjs";
|
|
9
9
|
import { hasPermission } from "../has-permission.mjs";
|
|
10
10
|
import { parseRoles } from "../organization.mjs";
|
|
11
|
+
import { runWithTransaction } from "@better-auth/core/context";
|
|
11
12
|
import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
|
|
12
13
|
import { createAuthEndpoint } from "@better-auth/core/api";
|
|
13
14
|
import * as z from "zod";
|
|
@@ -283,44 +284,58 @@ const acceptInvitation = (options) => createAuthEndpoint("/organization/accept-i
|
|
|
283
284
|
});
|
|
284
285
|
const acceptedI = await adapter.updateInvitation({
|
|
285
286
|
invitationId: ctx.body.invitationId,
|
|
286
|
-
status: "accepted"
|
|
287
|
+
status: "accepted",
|
|
288
|
+
fromStatus: "pending"
|
|
287
289
|
});
|
|
288
|
-
if (!acceptedI) throw APIError.from("BAD_REQUEST", ORGANIZATION_ERROR_CODES.
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
organizationId: invitation.organizationId
|
|
296
|
-
})) throw APIError.from("BAD_REQUEST", ORGANIZATION_ERROR_CODES.TEAM_NOT_FOUND);
|
|
297
|
-
await adapter.findOrCreateTeamMember({
|
|
298
|
-
teamId,
|
|
299
|
-
userId: session.user.id
|
|
300
|
-
});
|
|
301
|
-
if (typeof ctx.context.orgOptions.teams.maximumMembersPerTeam !== "undefined") {
|
|
302
|
-
if (await adapter.countTeamMembers({ teamId }) >= (typeof ctx.context.orgOptions.teams.maximumMembersPerTeam === "function" ? await ctx.context.orgOptions.teams.maximumMembersPerTeam({
|
|
290
|
+
if (!acceptedI) throw APIError.from("BAD_REQUEST", ORGANIZATION_ERROR_CODES.INVITATION_NOT_FOUND);
|
|
291
|
+
const member = await runWithTransaction(ctx.context.adapter, async () => {
|
|
292
|
+
if (ctx.context.orgOptions.teams && ctx.context.orgOptions.teams.enabled && "teamId" in acceptedI && acceptedI.teamId) {
|
|
293
|
+
const teamIds = acceptedI.teamId.split(",");
|
|
294
|
+
const onlyOne = teamIds.length === 1;
|
|
295
|
+
for (const teamId of teamIds) {
|
|
296
|
+
if (!await adapter.findTeamById({
|
|
303
297
|
teamId,
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
298
|
+
organizationId: acceptedI.organizationId
|
|
299
|
+
})) throw APIError.from("BAD_REQUEST", ORGANIZATION_ERROR_CODES.TEAM_NOT_FOUND);
|
|
300
|
+
const maximumMembersPerTeam = await resolveMaximumMembersPerTeam(ctx.context.orgOptions.teams, {
|
|
301
|
+
teamId,
|
|
302
|
+
organizationId: acceptedI.organizationId,
|
|
303
|
+
session
|
|
304
|
+
});
|
|
305
|
+
if (maximumMembersPerTeam !== void 0) {
|
|
306
|
+
if ((await adapter.addTeamMemberWithLimit({
|
|
307
|
+
teamId,
|
|
308
|
+
userId: session.user.id,
|
|
309
|
+
maximumMembersPerTeam
|
|
310
|
+
})).status === "limitReached") throw APIError.from("FORBIDDEN", ORGANIZATION_ERROR_CODES.TEAM_MEMBER_LIMIT_REACHED);
|
|
311
|
+
} else await adapter.findOrCreateTeamMember({
|
|
312
|
+
teamId,
|
|
313
|
+
userId: session.user.id
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
if (onlyOne) {
|
|
317
|
+
const teamId = teamIds[0];
|
|
318
|
+
await setSessionCookie(ctx, {
|
|
319
|
+
session: await adapter.setActiveTeam(session.session.token, teamId, ctx),
|
|
320
|
+
user: session.user
|
|
321
|
+
});
|
|
307
322
|
}
|
|
308
323
|
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
324
|
+
const createdMember = await adapter.createMember({
|
|
325
|
+
organizationId: acceptedI.organizationId,
|
|
326
|
+
userId: session.user.id,
|
|
327
|
+
role: acceptedI.role,
|
|
328
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
329
|
+
});
|
|
330
|
+
await adapter.setActiveOrganization(session.session.token, acceptedI.organizationId, ctx);
|
|
331
|
+
return createdMember;
|
|
332
|
+
}).catch(async (error) => {
|
|
333
|
+
await adapter.updateInvitation({
|
|
334
|
+
invitationId: ctx.body.invitationId,
|
|
335
|
+
status: "pending"
|
|
336
|
+
});
|
|
337
|
+
throw error;
|
|
322
338
|
});
|
|
323
|
-
await adapter.setActiveOrganization(session.session.token, invitation.organizationId, ctx);
|
|
324
339
|
if (options?.organizationHooks?.afterAcceptInvitation) await options?.organizationHooks.afterAcceptInvitation({
|
|
325
340
|
invitation: acceptedI,
|
|
326
341
|
member,
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { toZodSchema } from "../../../db/to-zod.mjs";
|
|
2
2
|
import { getSessionFromCtx, sessionMiddleware } from "../../../api/routes/session.mjs";
|
|
3
|
+
import { defaultRoles } from "../access/statement.mjs";
|
|
3
4
|
import { ORGANIZATION_ERROR_CODES } from "../error-codes.mjs";
|
|
4
|
-
import { getOrgAdapter } from "../adapter.mjs";
|
|
5
|
+
import { getOrgAdapter, resolveMaximumMembersPerTeam } from "../adapter.mjs";
|
|
5
6
|
import { orgMiddleware, orgSessionMiddleware } from "../call.mjs";
|
|
6
7
|
import { hasPermission } from "../has-permission.mjs";
|
|
7
8
|
import { parseRoles } from "../organization.mjs";
|
|
@@ -21,7 +22,7 @@ const addMember = (option) => {
|
|
|
21
22
|
fields: option?.schema?.member?.additionalFields || {},
|
|
22
23
|
isClientSide: true
|
|
23
24
|
});
|
|
24
|
-
return createAuthEndpoint({
|
|
25
|
+
return createAuthEndpoint.serverOnly({
|
|
25
26
|
method: "POST",
|
|
26
27
|
body: z.object({
|
|
27
28
|
...baseMemberSchema.shape,
|
|
@@ -87,11 +88,22 @@ const addMember = (option) => {
|
|
|
87
88
|
...response.data
|
|
88
89
|
};
|
|
89
90
|
}
|
|
90
|
-
const
|
|
91
|
-
|
|
91
|
+
const maximumMembersPerTeam = teamId ? await resolveMaximumMembersPerTeam(ctx.context.orgOptions.teams, {
|
|
92
|
+
teamId,
|
|
93
|
+
organizationId: orgId,
|
|
94
|
+
session
|
|
95
|
+
}) : void 0;
|
|
96
|
+
if (teamId) if (maximumMembersPerTeam !== void 0) {
|
|
97
|
+
if ((await adapter.addTeamMemberWithLimit({
|
|
98
|
+
teamId,
|
|
99
|
+
userId: user.id,
|
|
100
|
+
maximumMembersPerTeam
|
|
101
|
+
})).status === "limitReached") throw APIError.from("FORBIDDEN", ORGANIZATION_ERROR_CODES.TEAM_MEMBER_LIMIT_REACHED);
|
|
102
|
+
} else await adapter.findOrCreateTeamMember({
|
|
92
103
|
userId: user.id,
|
|
93
104
|
teamId
|
|
94
105
|
});
|
|
106
|
+
const createdMember = await adapter.createMember(memberData);
|
|
95
107
|
if (option?.organizationHooks?.afterAddMember) await option?.organizationHooks.afterAddMember({
|
|
96
108
|
member: createdMember,
|
|
97
109
|
user,
|
|
@@ -241,7 +253,31 @@ const updateMemberRole = (option) => createAuthEndpoint("/organization/update-me
|
|
|
241
253
|
const organizationId = ctx.body.organizationId || session.session.activeOrganizationId;
|
|
242
254
|
if (!organizationId) throw APIError.from("BAD_REQUEST", ORGANIZATION_ERROR_CODES.NO_ACTIVE_ORGANIZATION);
|
|
243
255
|
const adapter = getOrgAdapter(ctx.context, ctx.context.orgOptions);
|
|
244
|
-
const roleToSet = Array.isArray(ctx.body.role) ? ctx.body.role : ctx.body.role
|
|
256
|
+
const roleToSet = (Array.isArray(ctx.body.role) ? ctx.body.role : [ctx.body.role]).flatMap((role) => role.split(",")).map((role) => role.trim()).filter(Boolean);
|
|
257
|
+
if (roleToSet.length === 0) throw APIError.fromStatus("BAD_REQUEST");
|
|
258
|
+
const validStaticRoles = new Set([...Object.keys(defaultRoles), ...Object.keys(ctx.context.orgOptions.roles || {})]);
|
|
259
|
+
const unknownRoles = roleToSet.filter((role) => !validStaticRoles.has(role));
|
|
260
|
+
if (unknownRoles.length > 0) if (ctx.context.orgOptions.dynamicAccessControl?.enabled) {
|
|
261
|
+
const foundRoleNames = (await ctx.context.adapter.findMany({
|
|
262
|
+
model: "organizationRole",
|
|
263
|
+
where: [{
|
|
264
|
+
field: "organizationId",
|
|
265
|
+
value: organizationId
|
|
266
|
+
}, {
|
|
267
|
+
field: "role",
|
|
268
|
+
value: unknownRoles,
|
|
269
|
+
operator: "in"
|
|
270
|
+
}]
|
|
271
|
+
})).map((r) => r.role);
|
|
272
|
+
const stillInvalid = unknownRoles.filter((r) => !foundRoleNames.includes(r));
|
|
273
|
+
if (stillInvalid.length > 0) throw new APIError("BAD_REQUEST", {
|
|
274
|
+
code: ORGANIZATION_ERROR_CODES.ROLE_NOT_FOUND.code,
|
|
275
|
+
message: `${ORGANIZATION_ERROR_CODES.ROLE_NOT_FOUND.code}: ${stillInvalid.join(", ")}`
|
|
276
|
+
});
|
|
277
|
+
} else throw new APIError("BAD_REQUEST", {
|
|
278
|
+
code: ORGANIZATION_ERROR_CODES.ROLE_NOT_FOUND.code,
|
|
279
|
+
message: `${ORGANIZATION_ERROR_CODES.ROLE_NOT_FOUND.code}: ${unknownRoles.join(", ")}`
|
|
280
|
+
});
|
|
245
281
|
const member = await adapter.findMemberByOrgId({
|
|
246
282
|
userId: session.user.id,
|
|
247
283
|
organizationId
|
|
@@ -280,7 +316,7 @@ const updateMemberRole = (option) => createAuthEndpoint("/organization/update-me
|
|
|
280
316
|
const userBeingUpdated = await ctx.context.internalAdapter.findUserById(toBeUpdatedMember.userId);
|
|
281
317
|
if (!userBeingUpdated) throw APIError.fromStatus("BAD_REQUEST", { message: "User not found" });
|
|
282
318
|
const previousRole = toBeUpdatedMember.role;
|
|
283
|
-
const newRole = parseRoles(
|
|
319
|
+
const newRole = parseRoles(roleToSet);
|
|
284
320
|
if (option?.organizationHooks?.beforeUpdateMemberRole) {
|
|
285
321
|
const response = await option?.organizationHooks.beforeUpdateMemberRole({
|
|
286
322
|
member: toBeUpdatedMember,
|
|
@@ -2,10 +2,11 @@ import { setSessionCookie } from "../../../cookies/index.mjs";
|
|
|
2
2
|
import { toZodSchema } from "../../../db/to-zod.mjs";
|
|
3
3
|
import { getSessionFromCtx } from "../../../api/routes/session.mjs";
|
|
4
4
|
import { ORGANIZATION_ERROR_CODES } from "../error-codes.mjs";
|
|
5
|
-
import { getOrgAdapter } from "../adapter.mjs";
|
|
5
|
+
import { getOrgAdapter, resolveMaximumMembersPerTeam } from "../adapter.mjs";
|
|
6
6
|
import { orgMiddleware, orgSessionMiddleware } from "../call.mjs";
|
|
7
7
|
import { hasPermission } from "../has-permission.mjs";
|
|
8
8
|
import { teamSchema } from "../schema.mjs";
|
|
9
|
+
import { getCurrentAdapter, runWithTransaction } from "@better-auth/core/context";
|
|
9
10
|
import { APIError } from "@better-auth/core/error";
|
|
10
11
|
import { createAuthEndpoint } from "@better-auth/core/api";
|
|
11
12
|
import * as z from "zod";
|
|
@@ -185,7 +186,25 @@ const removeTeam = (options) => createAuthEndpoint("/organization/remove-team",
|
|
|
185
186
|
user: session?.user,
|
|
186
187
|
organization
|
|
187
188
|
});
|
|
188
|
-
await adapter
|
|
189
|
+
await runWithTransaction(ctx.context.adapter, async () => {
|
|
190
|
+
await adapter.deleteTeam(team.id);
|
|
191
|
+
const pendingInvitations = await adapter.findPendingInvitations({ organizationId });
|
|
192
|
+
const trx = await getCurrentAdapter(ctx.context.adapter);
|
|
193
|
+
for (const invitation of pendingInvitations) {
|
|
194
|
+
if (!("teamId" in invitation) || !invitation.teamId) continue;
|
|
195
|
+
const teamIds = invitation.teamId.split(",");
|
|
196
|
+
if (!teamIds.includes(team.id)) continue;
|
|
197
|
+
const remainingTeamIds = teamIds.filter((id) => id !== team.id);
|
|
198
|
+
await trx.update({
|
|
199
|
+
model: "invitation",
|
|
200
|
+
where: [{
|
|
201
|
+
field: "id",
|
|
202
|
+
value: invitation.id
|
|
203
|
+
}],
|
|
204
|
+
update: { teamId: remainingTeamIds.length > 0 ? remainingTeamIds.join(",") : null }
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
});
|
|
189
208
|
if (options?.organizationHooks?.afterDeleteTeam) await options?.organizationHooks.afterDeleteTeam({
|
|
190
209
|
team,
|
|
191
210
|
user: session?.user,
|
|
@@ -600,7 +619,21 @@ const addTeamMember = (options) => createAuthEndpoint("/organization/add-team-me
|
|
|
600
619
|
});
|
|
601
620
|
if (response && typeof response === "object" && "data" in response) {}
|
|
602
621
|
}
|
|
603
|
-
const
|
|
622
|
+
const maximumMembersPerTeam = await resolveMaximumMembersPerTeam(ctx.context.orgOptions.teams, {
|
|
623
|
+
teamId: ctx.body.teamId,
|
|
624
|
+
organizationId,
|
|
625
|
+
session
|
|
626
|
+
});
|
|
627
|
+
let teamMember;
|
|
628
|
+
if (maximumMembersPerTeam !== void 0) {
|
|
629
|
+
const result = await adapter.addTeamMemberWithLimit({
|
|
630
|
+
teamId: ctx.body.teamId,
|
|
631
|
+
userId: ctx.body.userId,
|
|
632
|
+
maximumMembersPerTeam
|
|
633
|
+
});
|
|
634
|
+
if (result.status === "limitReached") throw APIError.from("FORBIDDEN", ORGANIZATION_ERROR_CODES.TEAM_MEMBER_LIMIT_REACHED);
|
|
635
|
+
teamMember = result.member;
|
|
636
|
+
} else teamMember = await adapter.findOrCreateTeamMember({
|
|
604
637
|
teamId: ctx.body.teamId,
|
|
605
638
|
userId: ctx.body.userId
|
|
606
639
|
});
|
|
@@ -78,19 +78,19 @@ const signInPhoneNumber = (opts) => createAuthEndpoint("/sign-in/phone-number",
|
|
|
78
78
|
}
|
|
79
79
|
const credentialAccount = (await ctx.context.internalAdapter.findAccountByUserId(user.id)).find((a) => a.providerId === "credential");
|
|
80
80
|
if (!credentialAccount) {
|
|
81
|
-
ctx.context.logger.
|
|
81
|
+
ctx.context.logger.warn("Credential account not found");
|
|
82
82
|
throw APIError.from("UNAUTHORIZED", PHONE_NUMBER_ERROR_CODES.INVALID_PHONE_NUMBER_OR_PASSWORD);
|
|
83
83
|
}
|
|
84
84
|
const currentPassword = credentialAccount?.password;
|
|
85
85
|
if (!currentPassword) {
|
|
86
|
-
ctx.context.logger.
|
|
86
|
+
ctx.context.logger.warn("Password not found");
|
|
87
87
|
throw APIError.from("UNAUTHORIZED", PHONE_NUMBER_ERROR_CODES.UNEXPECTED_ERROR);
|
|
88
88
|
}
|
|
89
89
|
if (!await ctx.context.password.verify({
|
|
90
90
|
hash: currentPassword,
|
|
91
91
|
password
|
|
92
92
|
})) {
|
|
93
|
-
ctx.context.logger.
|
|
93
|
+
ctx.context.logger.warn("Invalid password");
|
|
94
94
|
throw APIError.from("UNAUTHORIZED", PHONE_NUMBER_ERROR_CODES.INVALID_PHONE_NUMBER_OR_PASSWORD);
|
|
95
95
|
}
|
|
96
96
|
const session = await ctx.context.internalAdapter.createSession(user.id, ctx.body.rememberMe === false);
|
|
@@ -280,24 +280,7 @@ const verifyPhoneNumber = (opts) => createAuthEndpoint("/phone-number/verify", {
|
|
|
280
280
|
code: ctx.body.code
|
|
281
281
|
}, ctx)) throw APIError.from("BAD_REQUEST", PHONE_NUMBER_ERROR_CODES.INVALID_OTP);
|
|
282
282
|
if (await ctx.context.internalAdapter.findVerificationValue(ctx.body.phoneNumber)) await ctx.context.internalAdapter.deleteVerificationByIdentifier(ctx.body.phoneNumber);
|
|
283
|
-
} else
|
|
284
|
-
const otp = await ctx.context.internalAdapter.findVerificationValue(ctx.body.phoneNumber);
|
|
285
|
-
if (!otp || otp.expiresAt < /* @__PURE__ */ new Date()) {
|
|
286
|
-
if (otp && otp.expiresAt < /* @__PURE__ */ new Date()) throw APIError.from("BAD_REQUEST", PHONE_NUMBER_ERROR_CODES.OTP_EXPIRED);
|
|
287
|
-
throw APIError.from("BAD_REQUEST", PHONE_NUMBER_ERROR_CODES.OTP_NOT_FOUND);
|
|
288
|
-
}
|
|
289
|
-
const [otpValue, attempts] = otp.value.split(":");
|
|
290
|
-
const allowedAttempts = opts?.allowedAttempts || 3;
|
|
291
|
-
if (attempts && parseInt(attempts) >= allowedAttempts) {
|
|
292
|
-
await ctx.context.internalAdapter.deleteVerificationByIdentifier(ctx.body.phoneNumber);
|
|
293
|
-
throw APIError.from("FORBIDDEN", PHONE_NUMBER_ERROR_CODES.TOO_MANY_ATTEMPTS);
|
|
294
|
-
}
|
|
295
|
-
if (otpValue !== ctx.body.code) {
|
|
296
|
-
await ctx.context.internalAdapter.updateVerificationByIdentifier(ctx.body.phoneNumber, { value: `${otpValue}:${parseInt(attempts || "0") + 1}` });
|
|
297
|
-
throw APIError.from("BAD_REQUEST", PHONE_NUMBER_ERROR_CODES.INVALID_OTP);
|
|
298
|
-
}
|
|
299
|
-
await ctx.context.internalAdapter.deleteVerificationByIdentifier(ctx.body.phoneNumber);
|
|
300
|
-
}
|
|
283
|
+
} else await verifyPhoneNumberOTP(ctx, opts, ctx.body.phoneNumber, ctx.body.code);
|
|
301
284
|
if (ctx.body.updatePhoneNumber) {
|
|
302
285
|
const session = await getSessionFromCtx(ctx);
|
|
303
286
|
if (!session) throw APIError.from("UNAUTHORIZED", BASE_ERROR_CODES.USER_NOT_FOUND);
|
|
@@ -432,20 +415,7 @@ const resetPasswordPhoneNumber = (opts) => createAuthEndpoint("/phone-number/res
|
|
|
432
415
|
} }
|
|
433
416
|
} }
|
|
434
417
|
}, async (ctx) => {
|
|
435
|
-
|
|
436
|
-
if (!verification) throw APIError.from("BAD_REQUEST", PHONE_NUMBER_ERROR_CODES.OTP_NOT_FOUND);
|
|
437
|
-
if (verification.expiresAt < /* @__PURE__ */ new Date()) throw APIError.from("BAD_REQUEST", PHONE_NUMBER_ERROR_CODES.OTP_EXPIRED);
|
|
438
|
-
const [otpValue, attempts] = verification.value.split(":");
|
|
439
|
-
const allowedAttempts = opts?.allowedAttempts || 3;
|
|
440
|
-
const phoneResetIdentifier = `${ctx.body.phoneNumber}-request-password-reset`;
|
|
441
|
-
if (attempts && parseInt(attempts) >= allowedAttempts) {
|
|
442
|
-
await ctx.context.internalAdapter.deleteVerificationByIdentifier(phoneResetIdentifier);
|
|
443
|
-
throw APIError.from("FORBIDDEN", PHONE_NUMBER_ERROR_CODES.TOO_MANY_ATTEMPTS);
|
|
444
|
-
}
|
|
445
|
-
if (ctx.body.otp !== otpValue) {
|
|
446
|
-
await ctx.context.internalAdapter.updateVerificationByIdentifier(phoneResetIdentifier, { value: `${otpValue}:${parseInt(attempts || "0") + 1}` });
|
|
447
|
-
throw APIError.from("BAD_REQUEST", PHONE_NUMBER_ERROR_CODES.INVALID_OTP);
|
|
448
|
-
}
|
|
418
|
+
await verifyPhoneNumberOTP(ctx, opts, `${ctx.body.phoneNumber}-request-password-reset`, ctx.body.otp);
|
|
449
419
|
const userRes = await ctx.context.adapter.findOne({
|
|
450
420
|
model: "user",
|
|
451
421
|
where: [{
|
|
@@ -468,11 +438,46 @@ const resetPasswordPhoneNumber = (opts) => createAuthEndpoint("/phone-number/res
|
|
|
468
438
|
password: hashedPassword
|
|
469
439
|
});
|
|
470
440
|
else await ctx.context.internalAdapter.updatePassword(user.id, hashedPassword);
|
|
471
|
-
await ctx.context.internalAdapter.deleteVerificationByIdentifier(phoneResetIdentifier);
|
|
472
441
|
if (ctx.context.options.emailAndPassword?.onPasswordReset) await ctx.context.options.emailAndPassword.onPasswordReset({ user }, ctx.request);
|
|
473
442
|
if (ctx.context.options.emailAndPassword?.revokeSessionsOnPasswordReset) await ctx.context.internalAdapter.deleteUserSessions(user.id);
|
|
474
443
|
return ctx.json({ status: true });
|
|
475
444
|
});
|
|
445
|
+
/**
|
|
446
|
+
* Atomically verifies a phone-number OTP against a stored verification value.
|
|
447
|
+
*
|
|
448
|
+
* Consuming the row is the race gate: the first concurrent caller wins the
|
|
449
|
+
* row, every racer behind it gets `null` and is rejected, so the same code can
|
|
450
|
+
* never satisfy two simultaneous verifications. On a wrong code that is still
|
|
451
|
+
* within the attempt budget, the row is recreated with the same value and
|
|
452
|
+
* expiry and an incremented attempt counter. Once the budget is exhausted the
|
|
453
|
+
* row is not recreated.
|
|
454
|
+
*/
|
|
455
|
+
async function verifyPhoneNumberOTP(ctx, opts, identifier, providedCode) {
|
|
456
|
+
const existing = await ctx.context.internalAdapter.findVerificationValue(identifier);
|
|
457
|
+
if (!existing) throw APIError.from("BAD_REQUEST", PHONE_NUMBER_ERROR_CODES.OTP_NOT_FOUND);
|
|
458
|
+
if (existing.expiresAt < /* @__PURE__ */ new Date()) {
|
|
459
|
+
await ctx.context.internalAdapter.deleteVerificationByIdentifier(identifier);
|
|
460
|
+
throw APIError.from("BAD_REQUEST", PHONE_NUMBER_ERROR_CODES.OTP_EXPIRED);
|
|
461
|
+
}
|
|
462
|
+
const allowedAttempts = opts?.allowedAttempts || 3;
|
|
463
|
+
const [, peekedAttempts] = existing.value.split(":");
|
|
464
|
+
if (peekedAttempts && parseInt(peekedAttempts) >= allowedAttempts) {
|
|
465
|
+
await ctx.context.internalAdapter.deleteVerificationByIdentifier(identifier);
|
|
466
|
+
throw APIError.from("FORBIDDEN", PHONE_NUMBER_ERROR_CODES.TOO_MANY_ATTEMPTS);
|
|
467
|
+
}
|
|
468
|
+
const consumed = await ctx.context.internalAdapter.consumeVerificationValue(identifier);
|
|
469
|
+
if (!consumed) throw APIError.from("BAD_REQUEST", PHONE_NUMBER_ERROR_CODES.INVALID_OTP);
|
|
470
|
+
const [otpValue, attempts] = consumed.value.split(":");
|
|
471
|
+
if (attempts && parseInt(attempts) >= allowedAttempts) throw APIError.from("FORBIDDEN", PHONE_NUMBER_ERROR_CODES.TOO_MANY_ATTEMPTS);
|
|
472
|
+
if (otpValue !== providedCode) {
|
|
473
|
+
await ctx.context.internalAdapter.createVerificationValue({
|
|
474
|
+
value: `${otpValue}:${parseInt(attempts || "0") + 1}`,
|
|
475
|
+
identifier,
|
|
476
|
+
expiresAt: consumed.expiresAt
|
|
477
|
+
});
|
|
478
|
+
throw APIError.from("BAD_REQUEST", PHONE_NUMBER_ERROR_CODES.INVALID_OTP);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
476
481
|
function generateOTP(size) {
|
|
477
482
|
return generateRandomString(size, "0-9");
|
|
478
483
|
}
|
|
@@ -68,8 +68,8 @@ const siwe = (options) => {
|
|
|
68
68
|
status: 400
|
|
69
69
|
});
|
|
70
70
|
try {
|
|
71
|
-
const verification = await ctx.context.internalAdapter.
|
|
72
|
-
if (!verification
|
|
71
|
+
const verification = await ctx.context.internalAdapter.consumeVerificationValue(`siwe:${walletAddress}:${chainId}`);
|
|
72
|
+
if (!verification) throw APIError.fromStatus("UNAUTHORIZED", {
|
|
73
73
|
message: "Unauthorized: Invalid or expired nonce",
|
|
74
74
|
status: 401,
|
|
75
75
|
code: "UNAUTHORIZED_INVALID_OR_EXPIRED_NONCE"
|
|
@@ -125,7 +125,6 @@ const siwe = (options) => {
|
|
|
125
125
|
message: "Unauthorized: Invalid SIWE signature",
|
|
126
126
|
status: 401
|
|
127
127
|
});
|
|
128
|
-
await ctx.context.internalAdapter.deleteVerificationByIdentifier(`siwe:${walletAddress}:${chainId}`);
|
|
129
128
|
let user = null;
|
|
130
129
|
const existingWalletAddress = await ctx.context.adapter.findOne({
|
|
131
130
|
model: "walletAddress",
|
|
@@ -249,7 +249,7 @@ const backupCode2fa = (opts) => {
|
|
|
249
249
|
backupCodes: backupCodes.backupCodes
|
|
250
250
|
});
|
|
251
251
|
}),
|
|
252
|
-
viewBackupCodes: createAuthEndpoint({
|
|
252
|
+
viewBackupCodes: createAuthEndpoint.serverOnly({
|
|
253
253
|
method: "POST",
|
|
254
254
|
body: viewBackupCodesBodySchema
|
|
255
255
|
}, async (ctx) => {
|