@better-auth/infra 0.2.8 → 0.2.10
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/client.d.mts +4 -5
- package/dist/client.mjs +5 -3
- package/dist/{dash-client-B6U89e1S.d.mts → dash-client-CZzJyRrV.d.mts} +1 -3
- package/dist/index.d.mts +583 -563
- package/dist/index.mjs +393 -81
- package/dist/native.d.mts +1 -1
- package/dist/native.mjs +5 -3
- package/dist/{pow-retry-BTL4g3RP.mjs → pow-retry-D2RRftJr.mjs} +34 -1
- package/package.json +13 -12
package/dist/index.mjs
CHANGED
|
@@ -1210,7 +1210,7 @@ function createSecurityClient(conn, $api, options, onSecurityEvent) {
|
|
|
1210
1210
|
return "";
|
|
1211
1211
|
}
|
|
1212
1212
|
},
|
|
1213
|
-
async checkImpossibleTravel(userId, currentLocation, visitorId) {
|
|
1213
|
+
async checkImpossibleTravel(userId, currentLocation, visitorId, ip = null) {
|
|
1214
1214
|
if (!options.impossibleTravel?.enabled || !currentLocation) return null;
|
|
1215
1215
|
try {
|
|
1216
1216
|
const data = await $api("/security/impossible-travel", {
|
|
@@ -1219,6 +1219,7 @@ function createSecurityClient(conn, $api, options, onSecurityEvent) {
|
|
|
1219
1219
|
userId,
|
|
1220
1220
|
visitorId,
|
|
1221
1221
|
location: currentLocation,
|
|
1222
|
+
ip,
|
|
1222
1223
|
config: options
|
|
1223
1224
|
}
|
|
1224
1225
|
});
|
|
@@ -1249,14 +1250,15 @@ function createSecurityClient(conn, $api, options, onSecurityEvent) {
|
|
|
1249
1250
|
/**
|
|
1250
1251
|
* Store user's last known location for impossible travel detection
|
|
1251
1252
|
*/
|
|
1252
|
-
async storeLastLocation(userId, location) {
|
|
1253
|
-
if (!location) return;
|
|
1253
|
+
async storeLastLocation(userId, location, ip = null) {
|
|
1254
|
+
if (!options.impossibleTravel?.enabled || !location) return;
|
|
1254
1255
|
try {
|
|
1255
1256
|
await $api("/security/store-last-login", {
|
|
1256
1257
|
method: "POST",
|
|
1257
1258
|
body: {
|
|
1258
1259
|
userId,
|
|
1259
|
-
location
|
|
1260
|
+
location,
|
|
1261
|
+
ip
|
|
1260
1262
|
}
|
|
1261
1263
|
});
|
|
1262
1264
|
} catch (error) {
|
|
@@ -1497,7 +1499,9 @@ const all = new Set([
|
|
|
1497
1499
|
"/send-verification-email",
|
|
1498
1500
|
"/change-email",
|
|
1499
1501
|
"/organization/invite-member",
|
|
1500
|
-
"/dash/organization/invite-member"
|
|
1502
|
+
"/dash/organization/invite-member",
|
|
1503
|
+
"/dash/create-user",
|
|
1504
|
+
"/dash/update-user"
|
|
1501
1505
|
]);
|
|
1502
1506
|
/**
|
|
1503
1507
|
* Path is one of `[
|
|
@@ -1516,7 +1520,9 @@ const all = new Set([
|
|
|
1516
1520
|
* '/send-verification-email',
|
|
1517
1521
|
* '/change-email',
|
|
1518
1522
|
* '/organization/invite-member',
|
|
1519
|
-
* '/dash/organization/invite-member'
|
|
1523
|
+
* '/dash/organization/invite-member',
|
|
1524
|
+
* '/dash/create-user',
|
|
1525
|
+
* '/dash/update-user'
|
|
1520
1526
|
* ]`.
|
|
1521
1527
|
* @param context Request context
|
|
1522
1528
|
* @param context.path Request path
|
|
@@ -2088,33 +2094,42 @@ const sentinel = (options) => {
|
|
|
2088
2094
|
emailValidation: opts.security?.emailValidation,
|
|
2089
2095
|
emailNormalization: opts.security?.emailNormalization,
|
|
2090
2096
|
databaseHooks: {
|
|
2091
|
-
user: {
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2097
|
+
user: {
|
|
2098
|
+
create: {
|
|
2099
|
+
async before(user, ctx) {
|
|
2100
|
+
if (!ctx) return;
|
|
2101
|
+
const visitorId = ctx.context.visitorId;
|
|
2102
|
+
if (visitorId && opts.security?.freeTrialAbuse?.enabled) {
|
|
2103
|
+
recordCheck(ctx, "free_trial_abuse");
|
|
2104
|
+
const abuseCheck = await securityService.checkFreeTrialAbuse(visitorId);
|
|
2105
|
+
if (abuseCheck.isAbuse && abuseCheck.action === "block") {
|
|
2106
|
+
setOutcome(ctx, "blocked", "free_trial_abuse", {
|
|
2107
|
+
accountCount: abuseCheck.accountCount,
|
|
2108
|
+
maxAccounts: abuseCheck.maxAccounts
|
|
2109
|
+
});
|
|
2110
|
+
emitEvaluation(ctx, trackEvent);
|
|
2111
|
+
throw new APIError("FORBIDDEN", { message: "Account creation is not allowed from this device." });
|
|
2112
|
+
}
|
|
2105
2113
|
}
|
|
2114
|
+
if (user.email && typeof user.email === "string" && isEmailNormalizationEnabled(opts.security)) return { data: {
|
|
2115
|
+
...user,
|
|
2116
|
+
email: normalizeEmail(user.email, ctx.context)
|
|
2117
|
+
} };
|
|
2118
|
+
},
|
|
2119
|
+
async after(user, ctx) {
|
|
2120
|
+
if (!ctx) return;
|
|
2121
|
+
const visitorId = ctx.context.visitorId;
|
|
2122
|
+
if (visitorId && opts.security?.freeTrialAbuse?.enabled) await ctx.context.runInBackgroundOrAwait(securityService.trackFreeTrialSignup(visitorId, user.id));
|
|
2106
2123
|
}
|
|
2124
|
+
},
|
|
2125
|
+
update: { async before(user, ctx) {
|
|
2126
|
+
if (!ctx) return;
|
|
2107
2127
|
if (user.email && typeof user.email === "string" && isEmailNormalizationEnabled(opts.security)) return { data: {
|
|
2108
2128
|
...user,
|
|
2109
2129
|
email: normalizeEmail(user.email, ctx.context)
|
|
2110
2130
|
} };
|
|
2111
|
-
}
|
|
2112
|
-
|
|
2113
|
-
if (!ctx) return;
|
|
2114
|
-
const visitorId = ctx.context.visitorId;
|
|
2115
|
-
if (visitorId && opts.security?.freeTrialAbuse?.enabled) await ctx.context.runInBackgroundOrAwait(securityService.trackFreeTrialSignup(visitorId, user.id));
|
|
2116
|
-
}
|
|
2117
|
-
} },
|
|
2131
|
+
} }
|
|
2132
|
+
},
|
|
2118
2133
|
session: { create: {
|
|
2119
2134
|
async before(session, ctx) {
|
|
2120
2135
|
if (!ctx) return;
|
|
@@ -2122,7 +2137,7 @@ const sentinel = (options) => {
|
|
|
2122
2137
|
const identification = ctx.context.identification;
|
|
2123
2138
|
if (session.userId && identification?.location && visitorId) {
|
|
2124
2139
|
recordCheck(ctx, "impossible_travel");
|
|
2125
|
-
const travelCheck = await securityService.checkImpossibleTravel(session.userId, identification.location, visitorId);
|
|
2140
|
+
const travelCheck = await securityService.checkImpossibleTravel(session.userId, identification.location, visitorId, identification.ip);
|
|
2126
2141
|
if (travelCheck?.isImpossible) {
|
|
2127
2142
|
if (travelCheck.action === "block") {
|
|
2128
2143
|
setOutcome(ctx, "blocked", "impossible_travel", {
|
|
@@ -2201,7 +2216,7 @@ const sentinel = (options) => {
|
|
|
2201
2216
|
}
|
|
2202
2217
|
}
|
|
2203
2218
|
}
|
|
2204
|
-
if (identification?.location) await ctx.context.runInBackgroundOrAwait(securityService.storeLastLocation(session.userId, identification.location));
|
|
2219
|
+
if (opts.security?.impossibleTravel?.enabled && identification?.location) await ctx.context.runInBackgroundOrAwait(securityService.storeLastLocation(session.userId, identification.location, identification.ip));
|
|
2205
2220
|
}
|
|
2206
2221
|
} }
|
|
2207
2222
|
}
|
|
@@ -2779,7 +2794,7 @@ const jwtValidateMiddleware = (options) => {
|
|
|
2779
2794
|
};
|
|
2780
2795
|
//#endregion
|
|
2781
2796
|
//#region src/version.ts
|
|
2782
|
-
const PLUGIN_VERSION = "0.2.
|
|
2797
|
+
const PLUGIN_VERSION = "0.2.10";
|
|
2783
2798
|
//#endregion
|
|
2784
2799
|
//#region src/routes/auth/config.ts
|
|
2785
2800
|
const PLUGIN_OPTIONS_EXCLUDE_KEYS = { stripe: new Set(["stripeClient"]) };
|
|
@@ -3794,13 +3809,13 @@ const completeInvitation = (options) => {
|
|
|
3794
3809
|
* Used by the platform to verify before sending invitation
|
|
3795
3810
|
* This is different from /dash/organization/check-user-by-email which also checks membership
|
|
3796
3811
|
*/
|
|
3797
|
-
const checkUserExists = (
|
|
3812
|
+
const checkUserExists = (options) => {
|
|
3798
3813
|
return createAuthEndpoint("/dash/check-user-exists", {
|
|
3799
3814
|
method: "POST",
|
|
3815
|
+
use: [jwtMiddleware(options)],
|
|
3800
3816
|
body: z$1.object({ email: z$1.email() })
|
|
3801
3817
|
}, async (ctx) => {
|
|
3802
3818
|
const { email } = ctx.body;
|
|
3803
|
-
if (!ctx.request?.headers.get("Authorization")) throw new APIError("UNAUTHORIZED", { message: "Authorization required" });
|
|
3804
3819
|
const normalizedEmail = normalizeEmail(email, ctx.context);
|
|
3805
3820
|
const existingUser = await ctx.context.internalAdapter.findUserByEmail(normalizedEmail).then((user) => user?.user);
|
|
3806
3821
|
return {
|
|
@@ -3810,6 +3825,23 @@ const checkUserExists = (_options) => {
|
|
|
3810
3825
|
});
|
|
3811
3826
|
};
|
|
3812
3827
|
//#endregion
|
|
3828
|
+
//#region src/routes/organizations/roles.ts
|
|
3829
|
+
const DEFAULT_ORG_MEMBER_ROLES = [
|
|
3830
|
+
"member",
|
|
3831
|
+
"admin",
|
|
3832
|
+
"owner"
|
|
3833
|
+
];
|
|
3834
|
+
function getOrganizationRoleKeys(orgOptions) {
|
|
3835
|
+
const roles = orgOptions?.roles;
|
|
3836
|
+
if (roles && typeof roles === "object" && Object.keys(roles).length > 0) return Object.keys(roles);
|
|
3837
|
+
return [...DEFAULT_ORG_MEMBER_ROLES];
|
|
3838
|
+
}
|
|
3839
|
+
const organizationMemberRoleInputSchema = z$1.string().trim().min(1, "Role is required").max(64, "Role is too long").regex(/^[a-zA-Z][a-zA-Z0-9_-]*$/, "Role must start with a letter and contain only letters, numbers, hyphens, and underscores");
|
|
3840
|
+
function validateOrganizationMemberRole(ctx, role, orgOptions) {
|
|
3841
|
+
const allowedRoles = getOrganizationRoleKeys(orgOptions);
|
|
3842
|
+
if (!allowedRoles.includes(role)) throw ctx.error("BAD_REQUEST", { message: `Invalid role. Allowed roles: ${allowedRoles.join(", ")}` });
|
|
3843
|
+
}
|
|
3844
|
+
//#endregion
|
|
3813
3845
|
//#region src/routes/organizations/invitations.ts
|
|
3814
3846
|
const listOrganizationInvitations = (options) => {
|
|
3815
3847
|
return createAuthEndpoint("/dash/organization/:id/invitations", {
|
|
@@ -3858,7 +3890,7 @@ const inviteMember = (options) => {
|
|
|
3858
3890
|
method: "POST",
|
|
3859
3891
|
body: z$1.object({
|
|
3860
3892
|
email: z$1.string(),
|
|
3861
|
-
role:
|
|
3893
|
+
role: organizationMemberRoleInputSchema,
|
|
3862
3894
|
invitedBy: z$1.string()
|
|
3863
3895
|
}),
|
|
3864
3896
|
use: [jwtMiddleware(options, z$1.object({
|
|
@@ -3868,6 +3900,7 @@ const inviteMember = (options) => {
|
|
|
3868
3900
|
}, async (ctx) => {
|
|
3869
3901
|
const { organizationId } = ctx.context.payload;
|
|
3870
3902
|
const organizationPlugin = requireOrganizationPlugin(ctx);
|
|
3903
|
+
validateOrganizationMemberRole(ctx, ctx.body.role, organizationPlugin.options);
|
|
3871
3904
|
if (!organizationPlugin.options?.sendInvitationEmail) throw ctx.error("BAD_REQUEST", { message: "Invitation email is not enabled" });
|
|
3872
3905
|
const invitedBy = await ctx.context.adapter.findOne({
|
|
3873
3906
|
model: "user",
|
|
@@ -4117,11 +4150,12 @@ const addMember = (options) => {
|
|
|
4117
4150
|
use: [jwtMiddleware(options, z$1.object({ organizationId: z$1.string() }))],
|
|
4118
4151
|
body: z$1.object({
|
|
4119
4152
|
userId: z$1.string(),
|
|
4120
|
-
role:
|
|
4153
|
+
role: organizationMemberRoleInputSchema
|
|
4121
4154
|
})
|
|
4122
4155
|
}, async (ctx) => {
|
|
4123
4156
|
const { organizationId } = ctx.context.payload;
|
|
4124
4157
|
const orgOptions = requireOrganizationPlugin(ctx).options || {};
|
|
4158
|
+
validateOrganizationMemberRole(ctx, ctx.body.role, orgOptions);
|
|
4125
4159
|
const organization = await ctx.context.adapter.findOne({
|
|
4126
4160
|
model: "organization",
|
|
4127
4161
|
where: [{
|
|
@@ -4250,11 +4284,12 @@ const updateMemberRole = (options) => {
|
|
|
4250
4284
|
use: [jwtMiddleware(options, z$1.object({ organizationId: z$1.string() }))],
|
|
4251
4285
|
body: z$1.object({
|
|
4252
4286
|
memberId: z$1.string(),
|
|
4253
|
-
role:
|
|
4287
|
+
role: organizationMemberRoleInputSchema
|
|
4254
4288
|
})
|
|
4255
4289
|
}, async (ctx) => {
|
|
4256
4290
|
const { organizationId } = ctx.context.payload;
|
|
4257
4291
|
const orgOptions = requireOrganizationPlugin(ctx).options || {};
|
|
4292
|
+
validateOrganizationMemberRole(ctx, ctx.body.role, orgOptions);
|
|
4258
4293
|
const existingMember = await ctx.context.adapter.findOne({
|
|
4259
4294
|
model: "member",
|
|
4260
4295
|
where: [{
|
|
@@ -4411,6 +4446,121 @@ async function withConcurrency(items, fn, options) {
|
|
|
4411
4446
|
return results;
|
|
4412
4447
|
}
|
|
4413
4448
|
//#endregion
|
|
4449
|
+
//#region src/routes/organizations/schemas.ts
|
|
4450
|
+
const DENIED_ORG_WRITE_KEYS = new Set([
|
|
4451
|
+
"id",
|
|
4452
|
+
"createdAt",
|
|
4453
|
+
"updatedAt"
|
|
4454
|
+
]);
|
|
4455
|
+
const CREATE_NON_PERSISTED_KEYS = new Set(["defaultTeamName"]);
|
|
4456
|
+
const CREATE_REQUEST_ONLY_KEYS = new Set(["userId", "skipDefaultTeam"]);
|
|
4457
|
+
const CORE_CREATE_KEYS = [
|
|
4458
|
+
"name",
|
|
4459
|
+
"slug",
|
|
4460
|
+
"logo"
|
|
4461
|
+
];
|
|
4462
|
+
const CORE_UPDATE_KEYS = [
|
|
4463
|
+
"name",
|
|
4464
|
+
"slug",
|
|
4465
|
+
"logo",
|
|
4466
|
+
"metadata"
|
|
4467
|
+
];
|
|
4468
|
+
function getOrganizationAdditionalFields(orgOptions) {
|
|
4469
|
+
return orgOptions?.schema?.organization?.additionalFields ?? {};
|
|
4470
|
+
}
|
|
4471
|
+
function getWritableOrganizationAdditionalFieldNames(orgOptions) {
|
|
4472
|
+
const additional = getOrganizationAdditionalFields(orgOptions);
|
|
4473
|
+
return Object.entries(additional).filter(([, field]) => {
|
|
4474
|
+
return field.input !== false;
|
|
4475
|
+
}).map(([name]) => name);
|
|
4476
|
+
}
|
|
4477
|
+
function getWritableOrganizationFieldNames(orgOptions, mode) {
|
|
4478
|
+
const keys = new Set(mode === "create" ? CORE_CREATE_KEYS : CORE_UPDATE_KEYS);
|
|
4479
|
+
for (const name of getWritableOrganizationAdditionalFieldNames(orgOptions)) keys.add(name);
|
|
4480
|
+
return keys;
|
|
4481
|
+
}
|
|
4482
|
+
/**
|
|
4483
|
+
* Returns only organization fields that may be persisted on create/update.
|
|
4484
|
+
* Strips unknown keys, denied keys, and create-only request fields.
|
|
4485
|
+
*/
|
|
4486
|
+
function pickWritableOrganizationFields(body, orgOptions, mode) {
|
|
4487
|
+
const allowed = getWritableOrganizationFieldNames(orgOptions, mode);
|
|
4488
|
+
const skip = new Set([
|
|
4489
|
+
...DENIED_ORG_WRITE_KEYS,
|
|
4490
|
+
...mode === "create" ? CREATE_NON_PERSISTED_KEYS : [],
|
|
4491
|
+
...mode === "create" ? CREATE_REQUEST_ONLY_KEYS : []
|
|
4492
|
+
]);
|
|
4493
|
+
const result = {};
|
|
4494
|
+
for (const [key, value] of Object.entries(body)) {
|
|
4495
|
+
if (skip.has(key) || !allowed.has(key) || value === void 0) continue;
|
|
4496
|
+
result[key] = value;
|
|
4497
|
+
}
|
|
4498
|
+
return result;
|
|
4499
|
+
}
|
|
4500
|
+
const BaseCreateOrgCoreBodySchema = z$1.object({
|
|
4501
|
+
name: z$1.string(),
|
|
4502
|
+
slug: z$1.string(),
|
|
4503
|
+
logo: z$1.string().optional(),
|
|
4504
|
+
defaultTeamName: z$1.string().optional()
|
|
4505
|
+
});
|
|
4506
|
+
const BaseUpdateOrgCoreBodySchema = z$1.object({
|
|
4507
|
+
logo: z$1.union([z$1.url(), z$1.literal("")]).optional(),
|
|
4508
|
+
name: z$1.string().optional(),
|
|
4509
|
+
slug: z$1.string().optional(),
|
|
4510
|
+
metadata: z$1.string().optional()
|
|
4511
|
+
});
|
|
4512
|
+
const CreateOrganizationBodySchema = BaseCreateOrgCoreBodySchema.catchall(z$1.unknown());
|
|
4513
|
+
const UpdateOrganizationBodySchema = BaseUpdateOrgCoreBodySchema.catchall(z$1.unknown());
|
|
4514
|
+
function createSchemaForDBField(field) {
|
|
4515
|
+
switch (field.type) {
|
|
4516
|
+
case "number": return field.required ? z$1.coerce.number() : z$1.coerce.number().optional();
|
|
4517
|
+
case "boolean": return field.required ? z$1.coerce.boolean() : z$1.coerce.boolean().optional();
|
|
4518
|
+
case "date": return field.required ? z$1.union([z$1.string().min(1), z$1.coerce.date()]) : z$1.union([z$1.string(), z$1.coerce.date()]).optional();
|
|
4519
|
+
default: return field.required ? z$1.string().min(1) : z$1.string().optional();
|
|
4520
|
+
}
|
|
4521
|
+
}
|
|
4522
|
+
/**
|
|
4523
|
+
* Validates required core + configured additional fields on create.
|
|
4524
|
+
* Call after pickWritableOrgFields if you need required additional field checks.
|
|
4525
|
+
*/
|
|
4526
|
+
function validateWritableCreateOrganizationFields(data, options) {
|
|
4527
|
+
const shape = {
|
|
4528
|
+
name: z$1.string().min(1),
|
|
4529
|
+
slug: z$1.string().min(1),
|
|
4530
|
+
logo: z$1.string().optional()
|
|
4531
|
+
};
|
|
4532
|
+
const additional = getOrganizationAdditionalFields(options);
|
|
4533
|
+
for (const [name, field] of Object.entries(additional)) {
|
|
4534
|
+
if (field.input === false) continue;
|
|
4535
|
+
shape[name] = createSchemaForDBField(field);
|
|
4536
|
+
}
|
|
4537
|
+
const result = z$1.object(shape).strict().safeParse(data);
|
|
4538
|
+
if (!result.success) throw result.error;
|
|
4539
|
+
}
|
|
4540
|
+
/**
|
|
4541
|
+
* Ensures at least one field is present on update and validates additional field types.
|
|
4542
|
+
*/
|
|
4543
|
+
function validateWritableOrganizationUpdateFields(data, orgOptions) {
|
|
4544
|
+
if (Object.keys(data).length === 0) throw new z$1.ZodError([{
|
|
4545
|
+
code: "custom",
|
|
4546
|
+
message: "No valid fields to update",
|
|
4547
|
+
path: []
|
|
4548
|
+
}]);
|
|
4549
|
+
const shape = {
|
|
4550
|
+
name: z$1.string().min(1).optional(),
|
|
4551
|
+
slug: z$1.string().min(1).optional(),
|
|
4552
|
+
logo: z$1.union([z$1.url(), z$1.literal("")]).optional(),
|
|
4553
|
+
metadata: z$1.string().optional()
|
|
4554
|
+
};
|
|
4555
|
+
const additional = getOrganizationAdditionalFields(orgOptions);
|
|
4556
|
+
for (const [name, field] of Object.entries(additional)) {
|
|
4557
|
+
if (field.input === false) continue;
|
|
4558
|
+
shape[name] = createSchemaForDBField(field);
|
|
4559
|
+
}
|
|
4560
|
+
const result = z$1.object(shape).partial().strict().safeParse(data);
|
|
4561
|
+
if (!result.success) throw result.error;
|
|
4562
|
+
}
|
|
4563
|
+
//#endregion
|
|
4414
4564
|
//#region src/routes/organizations/organization.ts
|
|
4415
4565
|
const listOrganizations = (options) => {
|
|
4416
4566
|
return createAuthEndpoint("/dash/list-organizations", {
|
|
@@ -4729,15 +4879,11 @@ const createOrganization = (options) => {
|
|
|
4729
4879
|
userId: z$1.string(),
|
|
4730
4880
|
skipDefaultTeam: z$1.boolean().optional().default(false)
|
|
4731
4881
|
}))],
|
|
4732
|
-
body:
|
|
4733
|
-
name: z$1.string(),
|
|
4734
|
-
slug: z$1.string(),
|
|
4735
|
-
logo: z$1.string().optional(),
|
|
4736
|
-
defaultTeamName: z$1.string().optional()
|
|
4737
|
-
})
|
|
4882
|
+
body: CreateOrganizationBodySchema
|
|
4738
4883
|
}, async (ctx) => {
|
|
4739
4884
|
const orgOptions = requireOrganizationPlugin(ctx).options || {};
|
|
4740
4885
|
const { userId } = ctx.context.payload;
|
|
4886
|
+
const defaultTeamName = ctx.body.defaultTeamName;
|
|
4741
4887
|
const user = await ctx.context.adapter.findOne({
|
|
4742
4888
|
model: "user",
|
|
4743
4889
|
where: [{
|
|
@@ -4753,10 +4899,13 @@ const createOrganization = (options) => {
|
|
|
4753
4899
|
value: ctx.body.slug
|
|
4754
4900
|
}]
|
|
4755
4901
|
}) > 0) throw ctx.error("BAD_REQUEST", { message: "Organization already exists" });
|
|
4756
|
-
let orgData =
|
|
4757
|
-
|
|
4758
|
-
|
|
4759
|
-
}
|
|
4902
|
+
let orgData = pickWritableOrganizationFields(ctx.body, orgOptions, "create");
|
|
4903
|
+
try {
|
|
4904
|
+
validateWritableCreateOrganizationFields(orgData, orgOptions);
|
|
4905
|
+
} catch (error) {
|
|
4906
|
+
if (error instanceof z$1.ZodError) throw ctx.error("BAD_REQUEST", { message: error.issues[0]?.message ?? "Invalid organization data" });
|
|
4907
|
+
throw error;
|
|
4908
|
+
}
|
|
4760
4909
|
if (orgOptions.organizationCreation?.beforeCreate) {
|
|
4761
4910
|
const response = await orgOptions.organizationCreation.beforeCreate({
|
|
4762
4911
|
organization: {
|
|
@@ -4765,20 +4914,20 @@ const createOrganization = (options) => {
|
|
|
4765
4914
|
},
|
|
4766
4915
|
user
|
|
4767
4916
|
}, ctx.request);
|
|
4768
|
-
if (response && typeof response === "object" && "data" in response) orgData = {
|
|
4917
|
+
if (response && typeof response === "object" && "data" in response) orgData = pickWritableOrganizationFields({
|
|
4769
4918
|
...orgData,
|
|
4770
4919
|
...response.data
|
|
4771
|
-
};
|
|
4920
|
+
}, orgOptions, "create");
|
|
4772
4921
|
}
|
|
4773
4922
|
if (orgOptions?.organizationHooks?.beforeCreateOrganization) {
|
|
4774
4923
|
const response = await orgOptions?.organizationHooks.beforeCreateOrganization({
|
|
4775
4924
|
organization: orgData,
|
|
4776
4925
|
user
|
|
4777
4926
|
});
|
|
4778
|
-
if (response && typeof response === "object" && "data" in response) orgData = {
|
|
4927
|
+
if (response && typeof response === "object" && "data" in response) orgData = pickWritableOrganizationFields({
|
|
4779
4928
|
...orgData,
|
|
4780
4929
|
...response.data
|
|
4781
|
-
};
|
|
4930
|
+
}, orgOptions, "create");
|
|
4782
4931
|
}
|
|
4783
4932
|
const organization = await ctx.context.adapter.create({
|
|
4784
4933
|
model: "organization",
|
|
@@ -4827,7 +4976,7 @@ const createOrganization = (options) => {
|
|
|
4827
4976
|
if (orgOptions?.teams?.enabled && orgOptions.teams.defaultTeam?.enabled !== false && !ctx.context.payload.skipDefaultTeam) {
|
|
4828
4977
|
let teamData = {
|
|
4829
4978
|
organizationId: organization.id,
|
|
4830
|
-
name:
|
|
4979
|
+
name: defaultTeamName || `${organization.name}`,
|
|
4831
4980
|
createdAt: /* @__PURE__ */ new Date()
|
|
4832
4981
|
};
|
|
4833
4982
|
if (orgOptions?.organizationHooks?.beforeCreateTeam) {
|
|
@@ -4891,12 +5040,7 @@ const updateOrganization = (options) => {
|
|
|
4891
5040
|
return createAuthEndpoint("/dash/organization/update", {
|
|
4892
5041
|
method: "POST",
|
|
4893
5042
|
use: [jwtMiddleware(options, z$1.object({ organizationId: z$1.string() }))],
|
|
4894
|
-
body:
|
|
4895
|
-
logo: z$1.url().optional(),
|
|
4896
|
-
name: z$1.string().optional(),
|
|
4897
|
-
slug: z$1.string().optional(),
|
|
4898
|
-
metadata: z$1.string().optional()
|
|
4899
|
-
})
|
|
5043
|
+
body: UpdateOrganizationBodySchema
|
|
4900
5044
|
}, async (ctx) => {
|
|
4901
5045
|
const { organizationId } = ctx.context.payload;
|
|
4902
5046
|
const orgOptions = requireOrganizationPlugin(ctx).options || {};
|
|
@@ -4931,7 +5075,13 @@ const updateOrganization = (options) => {
|
|
|
4931
5075
|
const owner = owners[0];
|
|
4932
5076
|
const updatedByUser = Array.isArray(owner.user) ? owner.user[0] : owner.user;
|
|
4933
5077
|
if (!updatedByUser) throw ctx.error("NOT_FOUND", { message: "Owner user not found" });
|
|
4934
|
-
let updateData =
|
|
5078
|
+
let updateData = pickWritableOrganizationFields(ctx.body, orgOptions, "update");
|
|
5079
|
+
try {
|
|
5080
|
+
validateWritableOrganizationUpdateFields(updateData, orgOptions);
|
|
5081
|
+
} catch (error) {
|
|
5082
|
+
if (error instanceof z$1.ZodError) throw ctx.error("BAD_REQUEST", { message: error.issues[0]?.message ?? "Invalid organization data" });
|
|
5083
|
+
throw error;
|
|
5084
|
+
}
|
|
4935
5085
|
if (typeof updateData.metadata === "string") try {
|
|
4936
5086
|
updateData.metadata = updateData.metadata === "" ? void 0 : JSON.parse(updateData.metadata);
|
|
4937
5087
|
} catch (parseError) {
|
|
@@ -4944,10 +5094,10 @@ const updateOrganization = (options) => {
|
|
|
4944
5094
|
user: updatedByUser,
|
|
4945
5095
|
member: owner
|
|
4946
5096
|
});
|
|
4947
|
-
if (response && typeof response === "object" && "data" in response) updateData = {
|
|
5097
|
+
if (response && typeof response === "object" && "data" in response) updateData = pickWritableOrganizationFields({
|
|
4948
5098
|
...updateData,
|
|
4949
5099
|
...response.data
|
|
4950
|
-
};
|
|
5100
|
+
}, orgOptions, "update");
|
|
4951
5101
|
}
|
|
4952
5102
|
const organization = await ctx.context.adapter.update({
|
|
4953
5103
|
model: "organization",
|
|
@@ -5528,13 +5678,13 @@ const revokeManySessions = (options) => createAuthEndpoint("/dash/sessions/revok
|
|
|
5528
5678
|
});
|
|
5529
5679
|
});
|
|
5530
5680
|
//#endregion
|
|
5531
|
-
//#region
|
|
5681
|
+
//#region ../utils/dist/ip.mjs
|
|
5532
5682
|
/**
|
|
5533
5683
|
* SSRF (Server-Side Request Forgery) Protection
|
|
5534
5684
|
*
|
|
5535
|
-
*
|
|
5536
|
-
* networks. Covers IPv4 private ranges,
|
|
5537
|
-
* bypass vectors.
|
|
5685
|
+
* IP and URL utilities to validate hostnames and URLs before server-side fetches.
|
|
5686
|
+
* Blocks requests to private/reserved networks. Covers IPv4 private ranges,
|
|
5687
|
+
* IPv6 private ranges, and IPv4-mapped IPv6 bypass vectors.
|
|
5538
5688
|
*/
|
|
5539
5689
|
function isPrivateIPv4(a, b) {
|
|
5540
5690
|
if (a === 10) return true;
|
|
@@ -5543,6 +5693,24 @@ function isPrivateIPv4(a, b) {
|
|
|
5543
5693
|
if (a === 127) return true;
|
|
5544
5694
|
if (a === 169 && b === 254) return true;
|
|
5545
5695
|
if (a === 0) return true;
|
|
5696
|
+
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
5697
|
+
return false;
|
|
5698
|
+
}
|
|
5699
|
+
/**
|
|
5700
|
+
* Loopback, RFC1918, link-local, CGNAT, ULA, documentation, NAT64 well-known,
|
|
5701
|
+
* IPv4-mapped private, unspecified, and multicast (RFC 4291 section 2.7).
|
|
5702
|
+
*/
|
|
5703
|
+
function ipv6GroupsAreNonPublic(groups) {
|
|
5704
|
+
if (groups.slice(0, 7).every((g) => g === 0) && groups[7] === 1) return true;
|
|
5705
|
+
if (groups.every((g) => g === 0)) return true;
|
|
5706
|
+
if ((groups[0] & 65472) === 65152) return true;
|
|
5707
|
+
if ((groups[0] & 65024) === 64512) return true;
|
|
5708
|
+
if ((groups[0] & 65280) === 65280) return true;
|
|
5709
|
+
if (groups[0] === 0 && groups[1] === 0 && groups[2] === 0 && groups[3] === 0 && groups[4] === 0 && groups[5] === 65535) return isPrivateIPv4(groups[6] >> 8 & 255, groups[6] & 255);
|
|
5710
|
+
if (groups[0] === 0 && groups[1] === 0 && groups[2] === 0 && groups[3] === 0 && groups[4] === 65535 && groups[5] === 0) return isPrivateIPv4(groups[6] >> 8 & 255, groups[6] & 255);
|
|
5711
|
+
if (groups[0] === 100 && groups[1] === 65435 && groups.slice(2, 6).every((g) => g === 0)) return isPrivateIPv4(groups[6] >> 8 & 255, groups[6] & 255);
|
|
5712
|
+
if (groups[0] === 8193 && groups[1] === 3512) return true;
|
|
5713
|
+
if (groups[0] === 256 && groups.slice(1, 4).every((g) => g === 0)) return true;
|
|
5546
5714
|
return false;
|
|
5547
5715
|
}
|
|
5548
5716
|
function parseIPv6(addr) {
|
|
@@ -5564,36 +5732,174 @@ function parseIPv6(addr) {
|
|
|
5564
5732
|
if (left.length !== 8) return null;
|
|
5565
5733
|
return left;
|
|
5566
5734
|
}
|
|
5735
|
+
/** Returns true if the hostname resolves to a private/reserved IP or is a local hostname. */
|
|
5567
5736
|
function isPrivateHost(hostname) {
|
|
5568
5737
|
if (hostname === "localhost" || hostname.endsWith(".local") || hostname.endsWith(".internal")) return true;
|
|
5569
5738
|
const bare = hostname.replace(/^\[|\]$/g, "");
|
|
5570
5739
|
const v4parts = bare.split(".").map(Number);
|
|
5571
5740
|
if (v4parts.length === 4 && v4parts.every((p) => !isNaN(p) && p >= 0 && p <= 255)) return isPrivateIPv4(v4parts[0], v4parts[1]);
|
|
5572
5741
|
const groups = parseIPv6(bare);
|
|
5573
|
-
if (groups && groups.length === 8)
|
|
5574
|
-
if (groups.slice(0, 7).every((g) => g === 0) && groups[7] === 1) return true;
|
|
5575
|
-
if (groups.every((g) => g === 0)) return true;
|
|
5576
|
-
if ((groups[0] & 65472) === 65152) return true;
|
|
5577
|
-
if ((groups[0] & 65024) === 64512) return true;
|
|
5578
|
-
if (groups[0] === 0 && groups[1] === 0 && groups[2] === 0 && groups[3] === 0 && groups[4] === 0 && groups[5] === 65535) return isPrivateIPv4(groups[6] >> 8 & 255, groups[6] & 255);
|
|
5579
|
-
if (groups[0] === 0 && groups[1] === 0 && groups[2] === 0 && groups[3] === 0 && groups[4] === 65535 && groups[5] === 0) return isPrivateIPv4(groups[6] >> 8 & 255, groups[6] & 255);
|
|
5580
|
-
if (groups[0] === 100 && groups[1] === 65435 && groups.slice(2, 6).every((g) => g === 0)) return isPrivateIPv4(groups[6] >> 8 & 255, groups[6] & 255);
|
|
5581
|
-
if (groups[0] === 8193 && groups[1] === 3512) return true;
|
|
5582
|
-
if (groups[0] === 256 && groups.slice(1, 4).every((g) => g === 0)) return true;
|
|
5583
|
-
}
|
|
5742
|
+
if (groups && groups.length === 8) return ipv6GroupsAreNonPublic(groups);
|
|
5584
5743
|
return false;
|
|
5585
5744
|
}
|
|
5586
|
-
|
|
5745
|
+
//#endregion
|
|
5746
|
+
//#region ../utils/dist/url.mjs
|
|
5747
|
+
/**
|
|
5748
|
+
* URL helpers: normalization, SSRF-safe parsing/validation, and path/query segments.
|
|
5749
|
+
*/
|
|
5750
|
+
const HTTP_PROTOCOLS = ["http:", "https:"];
|
|
5751
|
+
function isAllowedProtocol(protocol, allowedProtocols) {
|
|
5752
|
+
return (allowedProtocols?.length ? allowedProtocols : HTTP_PROTOCOLS).includes(protocol);
|
|
5753
|
+
}
|
|
5754
|
+
function toAllowedOriginSet(allowedOrigins) {
|
|
5755
|
+
const set = /* @__PURE__ */ new Set();
|
|
5756
|
+
for (const raw of allowedOrigins) try {
|
|
5757
|
+
const parsed = new URL(raw.trim());
|
|
5758
|
+
if (isAllowedProtocol(parsed.protocol, HTTP_PROTOCOLS)) set.add(parsed.origin);
|
|
5759
|
+
} catch {}
|
|
5760
|
+
return set;
|
|
5761
|
+
}
|
|
5762
|
+
function isAllowedOrigin(origin, allowedOrigins) {
|
|
5763
|
+
if (!allowedOrigins.length) return false;
|
|
5764
|
+
return toAllowedOriginSet(allowedOrigins).has(origin);
|
|
5765
|
+
}
|
|
5766
|
+
async function resolveHostnameAddresses(hostname) {
|
|
5767
|
+
try {
|
|
5768
|
+
const dns = await import("node:dns").catch((e) => {
|
|
5769
|
+
console.warn("Failed to load node:dns for DNS resolution", e);
|
|
5770
|
+
return null;
|
|
5771
|
+
});
|
|
5772
|
+
if (!dns?.promises?.resolve) return null;
|
|
5773
|
+
return await dns.promises.resolve(hostname);
|
|
5774
|
+
} catch {
|
|
5775
|
+
return null;
|
|
5776
|
+
}
|
|
5777
|
+
}
|
|
5778
|
+
function validateUrlSync(rawUrl, options = {}) {
|
|
5779
|
+
const trimmed = rawUrl.trim();
|
|
5780
|
+
let parsed;
|
|
5781
|
+
try {
|
|
5782
|
+
parsed = new URL(trimmed);
|
|
5783
|
+
} catch {
|
|
5784
|
+
return {
|
|
5785
|
+
ok: false,
|
|
5786
|
+
reason: "invalid_url",
|
|
5787
|
+
url: trimmed
|
|
5788
|
+
};
|
|
5789
|
+
}
|
|
5790
|
+
if (!isAllowedProtocol(parsed.protocol, options.allowedProtocols)) return {
|
|
5791
|
+
ok: false,
|
|
5792
|
+
reason: "disallowed_protocol",
|
|
5793
|
+
url: trimmed,
|
|
5794
|
+
protocol: parsed.protocol
|
|
5795
|
+
};
|
|
5796
|
+
if (parsed.username !== "" || parsed.password !== "") return {
|
|
5797
|
+
ok: false,
|
|
5798
|
+
reason: "embedded_credentials",
|
|
5799
|
+
url: trimmed
|
|
5800
|
+
};
|
|
5801
|
+
if (isPrivateHost(parsed.hostname)) return {
|
|
5802
|
+
ok: false,
|
|
5803
|
+
reason: "private_host",
|
|
5804
|
+
url: trimmed
|
|
5805
|
+
};
|
|
5806
|
+
if (options.allowedOrigins?.length) {
|
|
5807
|
+
if (!isAllowedOrigin(parsed.origin, options.allowedOrigins)) return {
|
|
5808
|
+
ok: false,
|
|
5809
|
+
reason: "disallowed_origin",
|
|
5810
|
+
url: trimmed,
|
|
5811
|
+
hostname: parsed.hostname,
|
|
5812
|
+
origin: parsed.origin
|
|
5813
|
+
};
|
|
5814
|
+
}
|
|
5815
|
+
return {
|
|
5816
|
+
ok: true,
|
|
5817
|
+
url: parsed
|
|
5818
|
+
};
|
|
5819
|
+
}
|
|
5820
|
+
/** SSRF checks with DNS resolution when available. */
|
|
5821
|
+
async function validateUrl(rawUrl, options = {}) {
|
|
5822
|
+
const sync = validateUrlSync(rawUrl, options);
|
|
5823
|
+
if (!sync.ok) return sync;
|
|
5824
|
+
if (options.dns === false) return sync;
|
|
5825
|
+
const addresses = await resolveHostnameAddresses(sync.url.hostname);
|
|
5826
|
+
if (addresses) {
|
|
5827
|
+
for (const addr of addresses) if (isPrivateHost(addr)) return {
|
|
5828
|
+
ok: false,
|
|
5829
|
+
reason: "private_dns",
|
|
5830
|
+
url: sync.url.href,
|
|
5831
|
+
hostname: sync.url.hostname,
|
|
5832
|
+
address: addr
|
|
5833
|
+
};
|
|
5834
|
+
}
|
|
5835
|
+
return sync;
|
|
5836
|
+
}
|
|
5837
|
+
function safeResolveUrl(raw, baseURL) {
|
|
5838
|
+
const trim = raw.trim();
|
|
5839
|
+
if (!trim) return null;
|
|
5840
|
+
if (trim.startsWith("//") || trim.includes("\\")) return null;
|
|
5841
|
+
if (/[\u0000-\u001F\u007F]/.test(trim)) return null;
|
|
5587
5842
|
try {
|
|
5588
|
-
|
|
5589
|
-
|
|
5590
|
-
if (isPrivateHost(parsed.hostname)) return null;
|
|
5591
|
-
return parsed;
|
|
5843
|
+
if (trim.startsWith("/")) return new URL(trim, baseURL);
|
|
5844
|
+
return new URL(trim);
|
|
5592
5845
|
} catch {
|
|
5593
5846
|
return null;
|
|
5594
5847
|
}
|
|
5595
5848
|
}
|
|
5596
5849
|
//#endregion
|
|
5850
|
+
//#region src/validation/ssrf.ts
|
|
5851
|
+
/**
|
|
5852
|
+
* SSRF protection for dash plugin outbound fetches.
|
|
5853
|
+
*
|
|
5854
|
+
* Uses @infra/utils at build time; tsdown bundles it into published dist so
|
|
5855
|
+
* consumers do not install the private workspace package.
|
|
5856
|
+
*/
|
|
5857
|
+
const REDIRECT_STATUSES = new Set([
|
|
5858
|
+
301,
|
|
5859
|
+
302,
|
|
5860
|
+
303,
|
|
5861
|
+
307,
|
|
5862
|
+
308
|
|
5863
|
+
]);
|
|
5864
|
+
const MAX_REDIRECTS = 10;
|
|
5865
|
+
var SsrfBlockedError = class extends Error {
|
|
5866
|
+
name = "SsrfBlockedError";
|
|
5867
|
+
};
|
|
5868
|
+
function isSsrfBlockedError(error) {
|
|
5869
|
+
return error instanceof SsrfBlockedError;
|
|
5870
|
+
}
|
|
5871
|
+
/** Validates URL hostname literals and resolved DNS addresses. */
|
|
5872
|
+
async function parseAndValidateUrl(url) {
|
|
5873
|
+
const result = await validateUrl(url);
|
|
5874
|
+
return result.ok ? result.url : null;
|
|
5875
|
+
}
|
|
5876
|
+
/**
|
|
5877
|
+
* Fetch with SSRF checks on the initial URL and every redirect target.
|
|
5878
|
+
*/
|
|
5879
|
+
async function $outbound(url, options = {}) {
|
|
5880
|
+
const { timeout, signal: callerSignal, ...fetchInit } = options;
|
|
5881
|
+
let currentUrl = url;
|
|
5882
|
+
for (let hop = 0;; hop++) {
|
|
5883
|
+
const validated = await parseAndValidateUrl(currentUrl);
|
|
5884
|
+
if (!validated) throw new SsrfBlockedError("Invalid or blocked URL");
|
|
5885
|
+
const signals = [];
|
|
5886
|
+
if (callerSignal) signals.push(callerSignal);
|
|
5887
|
+
if (timeout !== void 0 && timeout > 0) signals.push(AbortSignal.timeout(timeout));
|
|
5888
|
+
const response = await fetch(validated.href, {
|
|
5889
|
+
...fetchInit,
|
|
5890
|
+
redirect: "manual",
|
|
5891
|
+
signal: signals.length === 0 ? void 0 : signals.length === 1 ? signals[0] : AbortSignal.any(signals)
|
|
5892
|
+
});
|
|
5893
|
+
if (!REDIRECT_STATUSES.has(response.status)) return response;
|
|
5894
|
+
if (hop >= MAX_REDIRECTS) throw new SsrfBlockedError("Too many redirects");
|
|
5895
|
+
await response.body?.cancel().catch(() => {});
|
|
5896
|
+
const location = response.headers.get("location");
|
|
5897
|
+
const next = location ? safeResolveUrl(location, validated.href) : null;
|
|
5898
|
+
if (!next) throw new SsrfBlockedError("Invalid redirect location");
|
|
5899
|
+
currentUrl = next.href;
|
|
5900
|
+
}
|
|
5901
|
+
}
|
|
5902
|
+
//#endregion
|
|
5597
5903
|
//#region src/routes/plugin-session.ts
|
|
5598
5904
|
/**
|
|
5599
5905
|
* Builds an in-memory session context for calling plugin endpoints
|
|
@@ -5706,13 +6012,19 @@ const oidcConfigSchema = z$1.object({
|
|
|
5706
6012
|
async function resolveSAMLConfig(samlConfig, providerId, baseURL, ctx) {
|
|
5707
6013
|
let idpMetadataXml = samlConfig.idpMetadata?.metadata;
|
|
5708
6014
|
if (!idpMetadataXml && samlConfig.idpMetadata?.metadataUrl) {
|
|
5709
|
-
const validatedMetadataUrl = parseAndValidateUrl(samlConfig.idpMetadata.metadataUrl);
|
|
6015
|
+
const validatedMetadataUrl = await parseAndValidateUrl(samlConfig.idpMetadata.metadataUrl);
|
|
5710
6016
|
if (!validatedMetadataUrl) throw ctx.error("BAD_REQUEST", { message: "Invalid or blocked IdP metadata URL" });
|
|
5711
6017
|
try {
|
|
5712
|
-
const metadataResponse = await
|
|
6018
|
+
const metadataResponse = await $outbound(validatedMetadataUrl.href, {
|
|
6019
|
+
method: "GET",
|
|
6020
|
+
headers: { Accept: "application/xml, text/xml" },
|
|
6021
|
+
timeout: 15e3
|
|
6022
|
+
});
|
|
5713
6023
|
if (!metadataResponse.ok) throw ctx.error("BAD_REQUEST", { message: `Failed to fetch IdP metadata from URL: ${metadataResponse.status} ${metadataResponse.statusText}` });
|
|
5714
6024
|
idpMetadataXml = await metadataResponse.text();
|
|
5715
6025
|
} catch (e) {
|
|
6026
|
+
if (isSsrfBlockedError(e)) throw ctx.error("BAD_REQUEST", { message: "Invalid or blocked IdP metadata URL" });
|
|
6027
|
+
if (e instanceof APIError$1) throw e;
|
|
5716
6028
|
ctx.context.logger.error("[Dash] Failed to fetch IdP metadata from URL:", e);
|
|
5717
6029
|
throw ctx.error("BAD_REQUEST", { message: "Failed to fetch IdP metadata from URL" });
|
|
5718
6030
|
}
|