@better-auth/infra 0.2.5 → 0.2.6
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 +2 -2
- package/dist/client.mjs +1 -1
- package/dist/{dash-client-hJHp7l_X.d.mts → dash-client-CVCHbtoB.d.mts} +22 -1
- package/dist/index.d.mts +35 -1
- package/dist/index.mjs +342 -200
- package/dist/native.d.mts +2 -2
- package/dist/native.mjs +1 -1
- package/dist/{pow-BUuN_EKw.mjs → pow-CT9ehp8e.mjs} +33 -15
- package/package.json +6 -6
package/dist/client.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as
|
|
1
|
+
import { a as DashGetAuditLogsInput, i as DashGetAllAuditLogsInput, n as DashAuditLogsResponse, o as dashClient, r as DashClientOptions, t as DashAuditLog } from "./dash-client-CVCHbtoB.mjs";
|
|
2
2
|
import * as _$_better_fetch_fetch0 from "@better-fetch/fetch";
|
|
3
3
|
|
|
4
4
|
//#region src/sentinel/client.d.ts
|
|
@@ -44,4 +44,4 @@ declare const sentinelClient: (options?: SentinelClientOptions) => {
|
|
|
44
44
|
})[];
|
|
45
45
|
};
|
|
46
46
|
//#endregion
|
|
47
|
-
export { type DashAuditLog, type DashAuditLogsResponse, type DashClientOptions, type DashGetAuditLogsInput, type SentinelClientOptions, dashClient, sentinelClient };
|
|
47
|
+
export { type DashAuditLog, type DashAuditLogsResponse, type DashClientOptions, type DashGetAllAuditLogsInput, type DashGetAuditLogsInput, type SentinelClientOptions, dashClient, sentinelClient };
|
package/dist/client.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { r as KV_TIMEOUT_MS } from "./constants-DdWGfvz1.mjs";
|
|
2
|
-
import { a as identify, c as dashClient, i as generateRequestId, n as encodePoWSolution, r as solvePoWChallenge, s as hash, t as decodePoWChallenge } from "./pow-
|
|
2
|
+
import { a as identify, c as dashClient, i as generateRequestId, n as encodePoWSolution, r as solvePoWChallenge, s as hash, t as decodePoWChallenge } from "./pow-CT9ehp8e.mjs";
|
|
3
3
|
import { env } from "@better-auth/core/env";
|
|
4
4
|
//#region src/sentinel/fingerprint.ts
|
|
5
5
|
function murmurhash3(str, seed = 0) {
|
|
@@ -40,6 +40,15 @@ interface DashGetAuditLogsInput {
|
|
|
40
40
|
user?: UserLike | null;
|
|
41
41
|
session?: SessionLike | null;
|
|
42
42
|
}
|
|
43
|
+
interface DashGetAllAuditLogsInput {
|
|
44
|
+
limit?: number;
|
|
45
|
+
offset?: number;
|
|
46
|
+
organizationId?: string;
|
|
47
|
+
userId?: string;
|
|
48
|
+
eventType?: string;
|
|
49
|
+
identifier?: string;
|
|
50
|
+
session?: SessionLike | null;
|
|
51
|
+
}
|
|
43
52
|
interface DashClientOptions {
|
|
44
53
|
resolveUserId?: (input: {
|
|
45
54
|
userId?: string;
|
|
@@ -62,11 +71,23 @@ declare const dashClient: (options?: DashClientOptions) => {
|
|
|
62
71
|
data: DashAuditLogsResponse;
|
|
63
72
|
error: null;
|
|
64
73
|
}>;
|
|
74
|
+
getAllAuditLogs: (input?: DashGetAllAuditLogsInput) => Promise<{
|
|
75
|
+
data: null;
|
|
76
|
+
error: {
|
|
77
|
+
message?: string | undefined;
|
|
78
|
+
status: number;
|
|
79
|
+
statusText: string;
|
|
80
|
+
};
|
|
81
|
+
} | {
|
|
82
|
+
data: DashAuditLogsResponse;
|
|
83
|
+
error: null;
|
|
84
|
+
}>;
|
|
65
85
|
};
|
|
66
86
|
};
|
|
67
87
|
pathMethods: {
|
|
68
88
|
"/events/audit-logs": "GET";
|
|
89
|
+
"/events/all-audit-logs": "GET";
|
|
69
90
|
};
|
|
70
91
|
};
|
|
71
92
|
//#endregion
|
|
72
|
-
export {
|
|
93
|
+
export { DashGetAuditLogsInput as a, DashGetAllAuditLogsInput as i, DashAuditLogsResponse as n, dashClient as o, DashClientOptions as r, DashAuditLog as t };
|
package/dist/index.d.mts
CHANGED
|
@@ -726,7 +726,7 @@ interface DashIdRow {
|
|
|
726
726
|
id: string;
|
|
727
727
|
}
|
|
728
728
|
//#endregion
|
|
729
|
-
//#region ../../node_modules/.bun/@better-auth+scim@1.6.
|
|
729
|
+
//#region ../../node_modules/.bun/@better-auth+scim@1.6.9+191040a6ff16b29a/node_modules/@better-auth/scim/dist/index.d.mts
|
|
730
730
|
//#region src/types.d.ts
|
|
731
731
|
interface SCIMProvider {
|
|
732
732
|
id: string;
|
|
@@ -5637,6 +5637,40 @@ declare const dash: <O extends DashOptions>(options?: O) => {
|
|
|
5637
5637
|
eventType: zod.ZodOptional<zod.ZodString>;
|
|
5638
5638
|
}, zod_v4_core0.$strip>>;
|
|
5639
5639
|
}, UserEventsResponse>;
|
|
5640
|
+
getAllAuditLogs: _$better_call0.StrictEndpoint<"/events/all-audit-logs", {
|
|
5641
|
+
method: "GET";
|
|
5642
|
+
use: ((inputContext: _$better_call0.MiddlewareInputContext<_$better_call0.MiddlewareOptions>) => Promise<{
|
|
5643
|
+
session: {
|
|
5644
|
+
session: Record<string, any> & {
|
|
5645
|
+
id: string;
|
|
5646
|
+
createdAt: Date;
|
|
5647
|
+
updatedAt: Date;
|
|
5648
|
+
userId: string;
|
|
5649
|
+
expiresAt: Date;
|
|
5650
|
+
token: string;
|
|
5651
|
+
ipAddress?: string | null | undefined;
|
|
5652
|
+
userAgent?: string | null | undefined;
|
|
5653
|
+
};
|
|
5654
|
+
user: Record<string, any> & {
|
|
5655
|
+
id: string;
|
|
5656
|
+
createdAt: Date;
|
|
5657
|
+
updatedAt: Date;
|
|
5658
|
+
email: string;
|
|
5659
|
+
emailVerified: boolean;
|
|
5660
|
+
name: string;
|
|
5661
|
+
image?: string | null | undefined;
|
|
5662
|
+
};
|
|
5663
|
+
};
|
|
5664
|
+
}>)[];
|
|
5665
|
+
query: zod.ZodOptional<zod.ZodObject<{
|
|
5666
|
+
limit: zod.ZodOptional<zod.ZodUnion<[zod.ZodNumber, zod.ZodPipe<zod.ZodString, zod.ZodTransform<number, string>>]>>;
|
|
5667
|
+
offset: zod.ZodOptional<zod.ZodUnion<[zod.ZodNumber, zod.ZodPipe<zod.ZodString, zod.ZodTransform<number, string>>]>>;
|
|
5668
|
+
userId: zod.ZodOptional<zod.ZodString>;
|
|
5669
|
+
organizationId: zod.ZodOptional<zod.ZodString>;
|
|
5670
|
+
eventType: zod.ZodOptional<zod.ZodString>;
|
|
5671
|
+
identifier: zod.ZodOptional<zod.ZodString>;
|
|
5672
|
+
}, zod_v4_core0.$strip>>;
|
|
5673
|
+
}, UserEventsResponse>;
|
|
5640
5674
|
getEventTypes: _$better_call0.StrictEndpoint<"/events/types", {
|
|
5641
5675
|
method: "GET";
|
|
5642
5676
|
use: ((inputContext: _$better_call0.MiddlewareInputContext<_$better_call0.MiddlewareOptions>) => Promise<{
|
package/dist/index.mjs
CHANGED
|
@@ -997,6 +997,59 @@ function getCountryCodeFromRequest(request) {
|
|
|
997
997
|
return cc ? cc.toUpperCase() : void 0;
|
|
998
998
|
}
|
|
999
999
|
//#endregion
|
|
1000
|
+
//#region src/sentinel/evaluation.ts
|
|
1001
|
+
/** Lazily attach the buffer to the request context. */
|
|
1002
|
+
function getEvaluation(ctx) {
|
|
1003
|
+
if (!ctx.context.sentinelEvaluation) ctx.context.sentinelEvaluation = {
|
|
1004
|
+
checks: /* @__PURE__ */ new Set(),
|
|
1005
|
+
outcome: "passed",
|
|
1006
|
+
emitted: false
|
|
1007
|
+
};
|
|
1008
|
+
return ctx.context.sentinelEvaluation;
|
|
1009
|
+
}
|
|
1010
|
+
function recordCheck(ctx, name) {
|
|
1011
|
+
getEvaluation(ctx).checks.add(name);
|
|
1012
|
+
}
|
|
1013
|
+
/** Call immediately before throwing so the buffer reflects the final outcome. */
|
|
1014
|
+
function setOutcome(ctx, outcome, triggeredBy, details) {
|
|
1015
|
+
const ev = getEvaluation(ctx);
|
|
1016
|
+
ev.outcome = outcome;
|
|
1017
|
+
ev.triggeredBy = triggeredBy;
|
|
1018
|
+
if (details) ev.details = details;
|
|
1019
|
+
}
|
|
1020
|
+
/**
|
|
1021
|
+
* Emit one `security_check` event for the request. Idempotent per request and
|
|
1022
|
+
* skipped when no checks ran (unprotected route, or short-circuit before any
|
|
1023
|
+
* sentinel hook). `evaluatedChecks` carries the per-type breakdown — all under
|
|
1024
|
+
* the same Stripe meter, so the array is for support inspection only.
|
|
1025
|
+
*/
|
|
1026
|
+
function emitEvaluation(ctx, trackEvent, options = {}) {
|
|
1027
|
+
const ev = ctx.context.sentinelEvaluation;
|
|
1028
|
+
if (!ev || ev.emitted || ev.checks.size === 0) return;
|
|
1029
|
+
ev.emitted = true;
|
|
1030
|
+
const identification = ctx.context.identification ?? null;
|
|
1031
|
+
const visitorId = ctx.context.visitorId ?? null;
|
|
1032
|
+
const path = ctx.path ?? "";
|
|
1033
|
+
trackEvent({
|
|
1034
|
+
eventKey: visitorId || identification?.ip || "unknown",
|
|
1035
|
+
eventType: "security_check",
|
|
1036
|
+
eventDisplayName: ev.outcome === "blocked" ? "Security: blocked" : ev.outcome === "challenged" ? "Security: challenged" : "Security: passed",
|
|
1037
|
+
eventData: {
|
|
1038
|
+
outcome: ev.outcome,
|
|
1039
|
+
evaluatedChecks: [...ev.checks].sort(),
|
|
1040
|
+
triggeredBy: ev.triggeredBy,
|
|
1041
|
+
identifier: options.identifier,
|
|
1042
|
+
path,
|
|
1043
|
+
userAgent: options.userAgent,
|
|
1044
|
+
details: ev.details
|
|
1045
|
+
},
|
|
1046
|
+
ipAddress: identification?.ip || void 0,
|
|
1047
|
+
city: identification?.location?.city || void 0,
|
|
1048
|
+
country: identification?.location?.country?.name || void 0,
|
|
1049
|
+
countryCode: identification?.location?.country?.code || void 0
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
//#endregion
|
|
1000
1053
|
//#region src/sentinel/security.ts
|
|
1001
1054
|
async function hashForFingerprint(input) {
|
|
1002
1055
|
const data = new TextEncoder().encode(input);
|
|
@@ -1183,6 +1236,9 @@ function createSecurityClient(apiUrl, apiKey, options, onSecurityEvent) {
|
|
|
1183
1236
|
return null;
|
|
1184
1237
|
}
|
|
1185
1238
|
},
|
|
1239
|
+
/**
|
|
1240
|
+
* Store user's last known location for impossible travel detection
|
|
1241
|
+
*/
|
|
1186
1242
|
async storeLastLocation(userId, location) {
|
|
1187
1243
|
if (!location) return;
|
|
1188
1244
|
try {
|
|
@@ -1197,6 +1253,9 @@ function createSecurityClient(apiUrl, apiKey, options, onSecurityEvent) {
|
|
|
1197
1253
|
logger.error("[Dash] Store last location error:", error);
|
|
1198
1254
|
}
|
|
1199
1255
|
},
|
|
1256
|
+
/**
|
|
1257
|
+
* Check if a visitor has exceeded the free trial signup limit.
|
|
1258
|
+
*/
|
|
1200
1259
|
async checkFreeTrialAbuse(visitorId) {
|
|
1201
1260
|
if (!options.freeTrialAbuse?.enabled) return {
|
|
1202
1261
|
isAbuse: false,
|
|
@@ -1235,6 +1294,10 @@ function createSecurityClient(apiUrl, apiKey, options, onSecurityEvent) {
|
|
|
1235
1294
|
};
|
|
1236
1295
|
}
|
|
1237
1296
|
},
|
|
1297
|
+
/**
|
|
1298
|
+
* Track a new signup for free trial abuse detection.
|
|
1299
|
+
* Stores the userId for auditing purposes.
|
|
1300
|
+
*/
|
|
1238
1301
|
async trackFreeTrialSignup(visitorId, userId) {
|
|
1239
1302
|
if (!options.freeTrialAbuse?.enabled) return;
|
|
1240
1303
|
try {
|
|
@@ -1285,6 +1348,10 @@ function createSecurityClient(apiUrl, apiKey, options, onSecurityEvent) {
|
|
|
1285
1348
|
return { compromised: false };
|
|
1286
1349
|
}
|
|
1287
1350
|
},
|
|
1351
|
+
/**
|
|
1352
|
+
* Check if a user account is stale (inactive for a configured period)
|
|
1353
|
+
* This helps detect potential account takeover when dormant accounts become active
|
|
1354
|
+
*/
|
|
1288
1355
|
async checkStaleUser(userId, lastActiveAt) {
|
|
1289
1356
|
if (!options.staleUsers?.enabled) return { isStale: false };
|
|
1290
1357
|
try {
|
|
@@ -1317,6 +1384,9 @@ function createSecurityClient(apiUrl, apiKey, options, onSecurityEvent) {
|
|
|
1317
1384
|
return { isStale: false };
|
|
1318
1385
|
}
|
|
1319
1386
|
},
|
|
1387
|
+
/**
|
|
1388
|
+
* Send stale account reactivation notification to the user
|
|
1389
|
+
*/
|
|
1320
1390
|
async notifyStaleAccountUser(userEmail, userName, daysSinceLastActive, identification, appName) {
|
|
1321
1391
|
const loginTime = (/* @__PURE__ */ new Date()).toLocaleString("en-US", {
|
|
1322
1392
|
dateStyle: "long",
|
|
@@ -1344,6 +1414,9 @@ function createSecurityClient(apiUrl, apiKey, options, onSecurityEvent) {
|
|
|
1344
1414
|
if (result.success) logger.info(`[Dash] Stale account notification sent to user: ${userEmail}`);
|
|
1345
1415
|
else logger.error(`[Dash] Failed to send stale account user notification: ${result.error}`);
|
|
1346
1416
|
},
|
|
1417
|
+
/**
|
|
1418
|
+
* Send stale account reactivation notification to the admin
|
|
1419
|
+
*/
|
|
1347
1420
|
async notifyStaleAccountAdmin(adminEmail, userId, userEmail, userName, daysSinceLastActive, identification, appName) {
|
|
1348
1421
|
const loginTime = (/* @__PURE__ */ new Date()).toLocaleString("en-US", {
|
|
1349
1422
|
dateStyle: "long",
|
|
@@ -1522,7 +1595,12 @@ function createEmailValidator(options = {}) {
|
|
|
1522
1595
|
}
|
|
1523
1596
|
return null;
|
|
1524
1597
|
}
|
|
1525
|
-
return {
|
|
1598
|
+
return {
|
|
1599
|
+
/**
|
|
1600
|
+
* Validate an email address
|
|
1601
|
+
* Returns validation result - caller should use getPolicy() to determine action
|
|
1602
|
+
*/
|
|
1603
|
+
async validate(email, checkMx = true) {
|
|
1526
1604
|
const trimmed = email.trim();
|
|
1527
1605
|
const policy = await fetchPolicy();
|
|
1528
1606
|
if (!policy?.enabled) return {
|
|
@@ -1608,16 +1686,27 @@ function createEmailNormalizationHook() {
|
|
|
1608
1686
|
};
|
|
1609
1687
|
}
|
|
1610
1688
|
/**
|
|
1611
|
-
*
|
|
1689
|
+
* Records `email_validity` on every email-bearing request. On block, emits the
|
|
1690
|
+
* `security_check` event inline before throwing — the after-hook won't run on throw.
|
|
1612
1691
|
*/
|
|
1613
|
-
function createEmailValidationHook(validator,
|
|
1692
|
+
function createEmailValidationHook(validator, trackEvent) {
|
|
1614
1693
|
return {
|
|
1615
1694
|
matcher: allEmail,
|
|
1616
1695
|
handler: createAuthMiddleware(async (ctx) => {
|
|
1617
1696
|
const { email } = getEmail(ctx);
|
|
1618
1697
|
if (typeof email !== "string") return;
|
|
1619
1698
|
const trimmed = email.trim();
|
|
1620
|
-
|
|
1699
|
+
recordCheck(ctx, "email_validity");
|
|
1700
|
+
if (!isValidEmailFormatLocal(trimmed)) {
|
|
1701
|
+
if (trackEvent) {
|
|
1702
|
+
setOutcome(ctx, "blocked", "email_validity", {
|
|
1703
|
+
reason: "invalid_format",
|
|
1704
|
+
email: trimmed
|
|
1705
|
+
});
|
|
1706
|
+
emitEvaluation(ctx, trackEvent, { identifier: trimmed });
|
|
1707
|
+
}
|
|
1708
|
+
throw new APIError$1("BAD_REQUEST", { message: "Invalid email" });
|
|
1709
|
+
}
|
|
1621
1710
|
if (validator) {
|
|
1622
1711
|
const result = await validator.validate(trimmed);
|
|
1623
1712
|
const policy = result.policy;
|
|
@@ -1628,19 +1717,17 @@ function createEmailValidationHook(validator, onDisposableEmail) {
|
|
|
1628
1717
|
}
|
|
1629
1718
|
const action = policy.action;
|
|
1630
1719
|
if (!result.valid) {
|
|
1631
|
-
if (
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
reason: result.reason
|
|
1720
|
+
if (action === "allow") return;
|
|
1721
|
+
const message = result.reason === "no_mx_records" ? "This email domain cannot receive emails" : result.disposable || result.reason === "blocklist" ? "Disposable email addresses are not allowed" : result.reason === "known_invalid_email" || result.reason === "known_invalid_host" || result.reason === "reserved_tld" || result.reason === "provider_local_too_short" ? "This email address appears to be invalid" : "Invalid email";
|
|
1722
|
+
if (trackEvent) {
|
|
1723
|
+
setOutcome(ctx, "blocked", "email_validity", {
|
|
1724
|
+
reason: result.reason,
|
|
1636
1725
|
confidence: result.confidence,
|
|
1637
|
-
|
|
1638
|
-
path: ctx.path,
|
|
1639
|
-
action
|
|
1726
|
+
email: trimmed
|
|
1640
1727
|
});
|
|
1728
|
+
emitEvaluation(ctx, trackEvent, { identifier: trimmed });
|
|
1641
1729
|
}
|
|
1642
|
-
|
|
1643
|
-
throw new APIError$1("BAD_REQUEST", { message: result.reason === "no_mx_records" ? "This email domain cannot receive emails" : result.disposable || result.reason === "blocklist" ? "Disposable email addresses are not allowed" : result.reason === "known_invalid_email" || result.reason === "known_invalid_host" || result.reason === "reserved_tld" || result.reason === "provider_local_too_short" ? "This email address appears to be invalid" : "Invalid email" });
|
|
1730
|
+
throw new APIError$1("BAD_REQUEST", { message });
|
|
1644
1731
|
}
|
|
1645
1732
|
}
|
|
1646
1733
|
})
|
|
@@ -1668,7 +1755,7 @@ function createEmailValidationHook(validator, onDisposableEmail) {
|
|
|
1668
1755
|
* })
|
|
1669
1756
|
*/
|
|
1670
1757
|
function createEmailHooks(options = {}) {
|
|
1671
|
-
const { emailValidationOptions, emailNormalizationOptions, useApi = false, apiKey = "", apiUrl = INFRA_API_URL, kvUrl = INFRA_KV_URL,
|
|
1758
|
+
const { emailValidationOptions, emailNormalizationOptions, useApi = false, apiKey = "", apiUrl = INFRA_API_URL, kvUrl = INFRA_KV_URL, trackEvent } = options;
|
|
1672
1759
|
const emailConfig = {
|
|
1673
1760
|
enabled: true,
|
|
1674
1761
|
strictness: "medium",
|
|
@@ -1683,7 +1770,7 @@ function createEmailHooks(options = {}) {
|
|
|
1683
1770
|
kvUrl,
|
|
1684
1771
|
apiKey,
|
|
1685
1772
|
emailValidationOptions: emailConfig
|
|
1686
|
-
}) : void 0,
|
|
1773
|
+
}) : void 0, trackEvent)] : []] };
|
|
1687
1774
|
}
|
|
1688
1775
|
//#endregion
|
|
1689
1776
|
//#region src/validation/phone.ts
|
|
@@ -1898,32 +1985,6 @@ const ERROR_MESSAGES = {
|
|
|
1898
1985
|
compromised_password: "This password has been found in data breaches. Please choose a different password.",
|
|
1899
1986
|
impossible_travel: "Login blocked due to suspicious location change."
|
|
1900
1987
|
};
|
|
1901
|
-
const DISPLAY_NAMES = {
|
|
1902
|
-
geo_blocked: {
|
|
1903
|
-
challenge: "Security: geo challenge",
|
|
1904
|
-
block: "Security: geo blocked"
|
|
1905
|
-
},
|
|
1906
|
-
bot_detected: {
|
|
1907
|
-
challenge: "Security: bot challenge",
|
|
1908
|
-
block: "Security: bot blocked"
|
|
1909
|
-
},
|
|
1910
|
-
suspicious_ip_detected: {
|
|
1911
|
-
challenge: "Security: anonymous IP challenge",
|
|
1912
|
-
block: "Security: anonymous IP blocked"
|
|
1913
|
-
},
|
|
1914
|
-
rate_limited: {
|
|
1915
|
-
challenge: "Security: velocity challenge",
|
|
1916
|
-
block: "Security: velocity exceeded"
|
|
1917
|
-
},
|
|
1918
|
-
compromised_password: {
|
|
1919
|
-
challenge: "Security: breached password warning",
|
|
1920
|
-
block: "Security: breached password blocked"
|
|
1921
|
-
},
|
|
1922
|
-
impossible_travel: {
|
|
1923
|
-
challenge: "Security: impossible travel challenge",
|
|
1924
|
-
block: "Security: impossible travel blocked"
|
|
1925
|
-
}
|
|
1926
|
-
};
|
|
1927
1988
|
/**
|
|
1928
1989
|
* Throw a challenge error with appropriate headers
|
|
1929
1990
|
*/
|
|
@@ -1938,73 +1999,52 @@ function throwChallengeError(challenge, reason, message = "Please complete a sec
|
|
|
1938
1999
|
};
|
|
1939
2000
|
throw error;
|
|
1940
2001
|
}
|
|
1941
|
-
/**
|
|
1942
|
-
|
|
1943
|
-
*/
|
|
1944
|
-
function buildEventData(ctx, action, reason, confidence = 1, extraData) {
|
|
1945
|
-
const { visitorId, identification, path, identifier, userAgent } = ctx;
|
|
1946
|
-
const countryCode = identification?.location?.country?.code || void 0;
|
|
1947
|
-
return {
|
|
1948
|
-
eventKey: visitorId || identification?.ip || "unknown",
|
|
1949
|
-
eventType: action === "challenged" ? "security_challenged" : "security_blocked",
|
|
1950
|
-
eventDisplayName: DISPLAY_NAMES[reason]?.[action === "challenged" ? "challenge" : "block"] || `Security: ${reason}`,
|
|
1951
|
-
eventData: {
|
|
1952
|
-
action,
|
|
1953
|
-
reason,
|
|
1954
|
-
visitorId: visitorId || "",
|
|
1955
|
-
path,
|
|
1956
|
-
userAgent,
|
|
1957
|
-
identifier,
|
|
1958
|
-
confidence,
|
|
1959
|
-
...extraData
|
|
1960
|
-
},
|
|
1961
|
-
ipAddress: identification?.ip || void 0,
|
|
1962
|
-
city: identification?.location?.city || void 0,
|
|
1963
|
-
country: identification?.location?.country?.name || void 0,
|
|
1964
|
-
countryCode
|
|
1965
|
-
};
|
|
1966
|
-
}
|
|
1967
|
-
/**
|
|
1968
|
-
* Handle a security check result by tracking events and throwing appropriate errors
|
|
1969
|
-
*
|
|
1970
|
-
* @param verdict - The security verdict from the security service
|
|
1971
|
-
* @param ctx - Security check context with request information
|
|
1972
|
-
* @param trackEvent - Function to track security events
|
|
1973
|
-
* @param securityService - Security service for generating challenges
|
|
1974
|
-
* @returns True if the request should be blocked
|
|
1975
|
-
*/
|
|
1976
|
-
async function handleSecurityVerdict(verdict, ctx, trackEvent, securityService) {
|
|
2002
|
+
/** Emits the `security_check` event inline before throwing — the after-hook won't run on throw. */
|
|
2003
|
+
async function handleSecurityVerdict(verdict, hookCtx, checkCtx, trackEvent, securityService) {
|
|
1977
2004
|
if (verdict.action === "allow") return;
|
|
1978
2005
|
const reason = verdict.reason || "unknown";
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
const challenge = verdict.challenge || await securityService.generateChallenge(ctx.visitorId);
|
|
2006
|
+
if (verdict.action === "challenge" && checkCtx.visitorId) {
|
|
2007
|
+
const challenge = verdict.challenge || await securityService.generateChallenge(checkCtx.visitorId);
|
|
1982
2008
|
if (!challenge?.trim()) {
|
|
1983
2009
|
logger.warn("[Sentinel] Could not generate PoW challenge (service may be unavailable). Falling back to allow.");
|
|
1984
2010
|
return;
|
|
1985
2011
|
}
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
2012
|
+
setOutcome(hookCtx, "challenged", "server_security_check", {
|
|
2013
|
+
reason,
|
|
2014
|
+
...verdict.details
|
|
2015
|
+
});
|
|
2016
|
+
emitEvaluation(hookCtx, trackEvent, {
|
|
2017
|
+
identifier: checkCtx.identifier,
|
|
2018
|
+
userAgent: checkCtx.userAgent
|
|
2019
|
+
});
|
|
2020
|
+
throwChallengeError(challenge, reason);
|
|
2021
|
+
}
|
|
2022
|
+
if (verdict.action === "block") {
|
|
2023
|
+
setOutcome(hookCtx, "blocked", "server_security_check", {
|
|
2024
|
+
reason,
|
|
2025
|
+
...verdict.details
|
|
2026
|
+
});
|
|
2027
|
+
emitEvaluation(hookCtx, trackEvent, {
|
|
2028
|
+
identifier: checkCtx.identifier,
|
|
2029
|
+
userAgent: checkCtx.userAgent
|
|
2030
|
+
});
|
|
1990
2031
|
throw new APIError("FORBIDDEN", { message: ERROR_MESSAGES[reason] || "Access denied." });
|
|
1991
2032
|
}
|
|
1992
2033
|
}
|
|
1993
2034
|
/**
|
|
1994
|
-
*
|
|
1995
|
-
*
|
|
1996
|
-
* This replaces the multiple individual check calls with a single API call
|
|
1997
|
-
* that handles all security checks server-side.
|
|
2035
|
+
* Single API call from the plugin; server runs all configured rules and returns
|
|
2036
|
+
* one verdict — counts as one billable check.
|
|
1998
2037
|
*/
|
|
1999
|
-
async function runSecurityChecks(
|
|
2038
|
+
async function runSecurityChecks(hookCtx, checkCtx, securityService, trackEvent, powVerified) {
|
|
2000
2039
|
if (powVerified) return;
|
|
2040
|
+
recordCheck(hookCtx, "server_security_check");
|
|
2001
2041
|
await handleSecurityVerdict(await securityService.checkSecurity({
|
|
2002
|
-
visitorId:
|
|
2003
|
-
requestId:
|
|
2004
|
-
ip:
|
|
2005
|
-
path:
|
|
2006
|
-
identifier:
|
|
2007
|
-
}),
|
|
2042
|
+
visitorId: checkCtx.visitorId,
|
|
2043
|
+
requestId: checkCtx.identification?.requestId || null,
|
|
2044
|
+
ip: checkCtx.identification?.ip || null,
|
|
2045
|
+
path: checkCtx.path,
|
|
2046
|
+
identifier: checkCtx.identifier
|
|
2047
|
+
}), hookCtx, checkCtx, trackEvent, securityService);
|
|
2008
2048
|
}
|
|
2009
2049
|
//#endregion
|
|
2010
2050
|
//#region src/sentinel/sentinel.ts
|
|
@@ -2016,8 +2056,8 @@ const sentinel = (options) => {
|
|
|
2016
2056
|
const securityService = createSecurityClient(opts.apiUrl, opts.apiKey, opts.security || {}, (event) => {
|
|
2017
2057
|
trackEvent({
|
|
2018
2058
|
eventKey: event.visitorId || event.userId || "unknown",
|
|
2019
|
-
eventType:
|
|
2020
|
-
eventDisplayName: `Security: ${event.type.replace(/_/g, " ")}`,
|
|
2059
|
+
eventType: "security_signal",
|
|
2060
|
+
eventDisplayName: `Security signal: ${event.type.replace(/_/g, " ")}`,
|
|
2021
2061
|
eventData: {
|
|
2022
2062
|
type: event.type,
|
|
2023
2063
|
userId: event.userId || void 0,
|
|
@@ -2036,31 +2076,7 @@ const sentinel = (options) => {
|
|
|
2036
2076
|
apiKey: opts.apiKey,
|
|
2037
2077
|
apiUrl: opts.apiUrl,
|
|
2038
2078
|
kvUrl: opts.kvUrl,
|
|
2039
|
-
|
|
2040
|
-
const isNoMxRecord = data.reason === "no_mx_records";
|
|
2041
|
-
const reason = isNoMxRecord ? "no_mx_records" : "disposable_email";
|
|
2042
|
-
const label = isNoMxRecord ? "No MX Records" : "Disposable Email";
|
|
2043
|
-
const actionLabel = data.action === "allow" ? "allowed" : "blocked";
|
|
2044
|
-
const actionVerb = data.action === "allow" ? "detected" : "blocked";
|
|
2045
|
-
const eventType = data.action === "allow" ? "security_allowed" : "security_blocked";
|
|
2046
|
-
const displayName = isNoMxRecord ? `Security: invalid email domain ${actionVerb}` : `Security: disposable email ${actionVerb}`;
|
|
2047
|
-
const description = isNoMxRecord ? `${actionVerb.charAt(0).toUpperCase() + actionVerb.slice(1)} signup attempt with invalid email domain (no MX records): ${data.email}` : `${actionVerb.charAt(0).toUpperCase() + actionVerb.slice(1)} signup attempt with disposable email: ${data.email} (${data.reason}, ${data.confidence} confidence)`;
|
|
2048
|
-
logger.info(`[Sentinel] Tracking ${reason} event for email: ${data.email} (action: ${actionLabel})`);
|
|
2049
|
-
trackEvent({
|
|
2050
|
-
eventKey: data.email,
|
|
2051
|
-
eventType,
|
|
2052
|
-
eventDisplayName: displayName,
|
|
2053
|
-
eventData: {
|
|
2054
|
-
action: actionLabel,
|
|
2055
|
-
reason,
|
|
2056
|
-
identifier: data.email,
|
|
2057
|
-
detectionLabel: label,
|
|
2058
|
-
description,
|
|
2059
|
-
path: data.path
|
|
2060
|
-
},
|
|
2061
|
-
ipAddress: data.ip
|
|
2062
|
-
});
|
|
2063
|
-
}
|
|
2079
|
+
trackEvent
|
|
2064
2080
|
});
|
|
2065
2081
|
return {
|
|
2066
2082
|
id: "sentinel",
|
|
@@ -2075,8 +2091,16 @@ const sentinel = (options) => {
|
|
|
2075
2091
|
if (!ctx) return;
|
|
2076
2092
|
const visitorId = ctx.context.visitorId;
|
|
2077
2093
|
if (visitorId && opts.security?.freeTrialAbuse?.enabled) {
|
|
2094
|
+
recordCheck(ctx, "free_trial_abuse");
|
|
2078
2095
|
const abuseCheck = await securityService.checkFreeTrialAbuse(visitorId);
|
|
2079
|
-
if (abuseCheck.isAbuse && abuseCheck.action === "block")
|
|
2096
|
+
if (abuseCheck.isAbuse && abuseCheck.action === "block") {
|
|
2097
|
+
setOutcome(ctx, "blocked", "free_trial_abuse", {
|
|
2098
|
+
accountCount: abuseCheck.accountCount,
|
|
2099
|
+
maxAccounts: abuseCheck.maxAccounts
|
|
2100
|
+
});
|
|
2101
|
+
emitEvaluation(ctx, trackEvent);
|
|
2102
|
+
throw new APIError("FORBIDDEN", { message: "Account creation is not allowed from this device." });
|
|
2103
|
+
}
|
|
2080
2104
|
}
|
|
2081
2105
|
if (user.email && typeof user.email === "string" && isEmailNormalizationEnabled(opts.security)) return { data: {
|
|
2082
2106
|
...user,
|
|
@@ -2095,10 +2119,29 @@ const sentinel = (options) => {
|
|
|
2095
2119
|
const visitorId = ctx.context.visitorId;
|
|
2096
2120
|
const identification = ctx.context.identification;
|
|
2097
2121
|
if (session.userId && identification?.location && visitorId) {
|
|
2122
|
+
recordCheck(ctx, "impossible_travel");
|
|
2098
2123
|
const travelCheck = await securityService.checkImpossibleTravel(session.userId, identification.location, visitorId);
|
|
2099
2124
|
if (travelCheck?.isImpossible) {
|
|
2100
|
-
if (travelCheck.action === "block")
|
|
2101
|
-
|
|
2125
|
+
if (travelCheck.action === "block") {
|
|
2126
|
+
setOutcome(ctx, "blocked", "impossible_travel", {
|
|
2127
|
+
from: travelCheck.from,
|
|
2128
|
+
to: travelCheck.to,
|
|
2129
|
+
distance: travelCheck.distance,
|
|
2130
|
+
speedRequired: travelCheck.speedRequired
|
|
2131
|
+
});
|
|
2132
|
+
emitEvaluation(ctx, trackEvent);
|
|
2133
|
+
throw new APIError("FORBIDDEN", { message: "Login blocked due to suspicious location change." });
|
|
2134
|
+
}
|
|
2135
|
+
if (travelCheck.action === "challenge" && travelCheck.challenge) {
|
|
2136
|
+
setOutcome(ctx, "challenged", "impossible_travel", {
|
|
2137
|
+
from: travelCheck.from,
|
|
2138
|
+
to: travelCheck.to,
|
|
2139
|
+
distance: travelCheck.distance,
|
|
2140
|
+
speedRequired: travelCheck.speedRequired
|
|
2141
|
+
});
|
|
2142
|
+
emitEvaluation(ctx, trackEvent);
|
|
2143
|
+
throwChallengeError(travelCheck.challenge, "impossible_travel", "Unusual login location detected. Please complete a security check.");
|
|
2144
|
+
}
|
|
2102
2145
|
}
|
|
2103
2146
|
}
|
|
2104
2147
|
},
|
|
@@ -2128,6 +2171,7 @@ const sentinel = (options) => {
|
|
|
2128
2171
|
if (await securityService.checkUnknownDevice(session.userId, visitorId) && user?.email) await ctx.context.runInBackgroundOrAwait(securityService.notifyUnknownDevice(session.userId, user.email, identification));
|
|
2129
2172
|
}
|
|
2130
2173
|
if (opts.security?.staleUsers?.enabled && user) {
|
|
2174
|
+
recordCheck(ctx, "stale_users");
|
|
2131
2175
|
const lastActiveAtForStale = activityTrackingEnabled ? user.lastActiveAt ?? null : null;
|
|
2132
2176
|
const staleCheck = await securityService.checkStaleUser(session.userId, lastActiveAtForStale);
|
|
2133
2177
|
if (staleCheck.isStale) {
|
|
@@ -2138,31 +2182,21 @@ const sentinel = (options) => {
|
|
|
2138
2182
|
if (notificationPromises.length > 0) Promise.all(notificationPromises).catch((error) => {
|
|
2139
2183
|
logger.error("[Sentinel] Failed to send stale account notifications:", error);
|
|
2140
2184
|
});
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
eventType: "security_stale_account",
|
|
2144
|
-
eventDisplayName: "Security: stale account reactivated",
|
|
2145
|
-
eventData: {
|
|
2146
|
-
action: staleCheck.action === "block" ? "blocked" : staleCheck.action === "challenge" ? "challenged" : "logged",
|
|
2147
|
-
reason: "stale_account_reactivation",
|
|
2185
|
+
if (staleCheck.action === "block") {
|
|
2186
|
+
setOutcome(ctx, "blocked", "stale_users", {
|
|
2148
2187
|
userId: session.userId,
|
|
2149
2188
|
daysSinceLastActive: staleCheck.daysSinceLastActive,
|
|
2150
2189
|
staleDays: staleCheck.staleDays,
|
|
2151
2190
|
lastActiveAt: staleCheck.lastActiveAt,
|
|
2152
2191
|
notifyUser: staleCheck.notifyUser,
|
|
2153
|
-
notifyAdmin: staleCheck.notifyAdmin
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
});
|
|
2162
|
-
if (staleCheck.action === "block") throw new APIError("FORBIDDEN", {
|
|
2163
|
-
message: "This account has been inactive for an extended period. Please contact support to reactivate.",
|
|
2164
|
-
code: "STALE_ACCOUNT"
|
|
2165
|
-
});
|
|
2192
|
+
notifyAdmin: staleCheck.notifyAdmin
|
|
2193
|
+
});
|
|
2194
|
+
emitEvaluation(ctx, trackEvent);
|
|
2195
|
+
throw new APIError("FORBIDDEN", {
|
|
2196
|
+
message: "This account has been inactive for an extended period. Please contact support to reactivate.",
|
|
2197
|
+
code: "STALE_ACCOUNT"
|
|
2198
|
+
});
|
|
2199
|
+
}
|
|
2166
2200
|
}
|
|
2167
2201
|
}
|
|
2168
2202
|
if (identification?.location) await ctx.context.runInBackgroundOrAwait(securityService.storeLastLocation(session.userId, identification.location));
|
|
@@ -2225,52 +2259,23 @@ const sentinel = (options) => {
|
|
|
2225
2259
|
const requestBody = ctx.body;
|
|
2226
2260
|
const identifier = requestBody?.email || requestBody?.phone || requestBody?.username || void 0;
|
|
2227
2261
|
const powVerified = ctx.context.powVerified === true;
|
|
2228
|
-
if (visitorId && powVerified)
|
|
2229
|
-
eventKey: visitorId,
|
|
2230
|
-
eventType: "security_allowed",
|
|
2231
|
-
eventDisplayName: "Security: challenge completed",
|
|
2232
|
-
eventData: {
|
|
2233
|
-
action: "allowed",
|
|
2234
|
-
reason: "pow_verified",
|
|
2235
|
-
visitorId,
|
|
2236
|
-
path: ctx.path,
|
|
2237
|
-
userAgent: ctx.headers?.get?.("user-agent") || "",
|
|
2238
|
-
identifier,
|
|
2239
|
-
detectionLabel: "Challenge Completed",
|
|
2240
|
-
description: identifier ? `Successfully completed security challenge for "${identifier}"` : "Successfully completed security challenge"
|
|
2241
|
-
},
|
|
2242
|
-
ipAddress: identification?.ip || void 0,
|
|
2243
|
-
city: identification?.location?.city || void 0,
|
|
2244
|
-
country: identification?.location?.country?.name || void 0,
|
|
2245
|
-
countryCode: identification?.location?.country?.code || void 0
|
|
2246
|
-
});
|
|
2262
|
+
if (visitorId && powVerified) recordCheck(ctx, "pow_challenge");
|
|
2247
2263
|
if (visitorId) {
|
|
2264
|
+
recordCheck(ctx, "credential_stuffing");
|
|
2248
2265
|
if (await securityService.isBlocked(visitorId)) {
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
path: ctx.path,
|
|
2258
|
-
userAgent: ctx.headers?.get?.("user-agent") || "",
|
|
2259
|
-
identifier,
|
|
2260
|
-
detectionType: "cooldown_active",
|
|
2261
|
-
detectionLabel: "Credential Stuffing",
|
|
2262
|
-
description: identifier ? `Visitor attempting "${identifier}" still in cooldown from prior detection` : "Visitor still in cooldown from prior detection",
|
|
2263
|
-
confidence: 1
|
|
2264
|
-
},
|
|
2265
|
-
ipAddress: identification?.ip || void 0,
|
|
2266
|
-
city: identification?.location?.city || void 0,
|
|
2267
|
-
country: identification?.location?.country?.name || void 0,
|
|
2268
|
-
countryCode: identification?.location?.country?.code || void 0
|
|
2266
|
+
setOutcome(ctx, "blocked", "credential_stuffing", {
|
|
2267
|
+
reason: "cooldown_active",
|
|
2268
|
+
visitorId,
|
|
2269
|
+
confidence: 1
|
|
2270
|
+
});
|
|
2271
|
+
emitEvaluation(ctx, trackEvent, {
|
|
2272
|
+
identifier,
|
|
2273
|
+
userAgent: ctx.headers?.get?.("user-agent") || ""
|
|
2269
2274
|
});
|
|
2270
2275
|
throw new APIError(403, { message: "Too many failed attempts. Please try again later." });
|
|
2271
2276
|
}
|
|
2272
2277
|
}
|
|
2273
|
-
await runSecurityChecks({
|
|
2278
|
+
await runSecurityChecks(ctx, {
|
|
2274
2279
|
path: ctx.path,
|
|
2275
2280
|
identifier,
|
|
2276
2281
|
visitorId,
|
|
@@ -2286,12 +2291,20 @@ const sentinel = (options) => {
|
|
|
2286
2291
|
const body = ctx.body;
|
|
2287
2292
|
const passwordToCheck = body?.newPassword || body?.password;
|
|
2288
2293
|
if (passwordToCheck) {
|
|
2294
|
+
recordCheck(ctx, "compromised_password");
|
|
2289
2295
|
const compromisedResult = await securityService.checkCompromisedPassword(passwordToCheck);
|
|
2290
2296
|
if (compromisedResult.compromised && compromisedResult.action) {
|
|
2291
|
-
if (compromisedResult.action === "block")
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2297
|
+
if (compromisedResult.action === "block") {
|
|
2298
|
+
setOutcome(ctx, "blocked", "compromised_password", { breachCount: compromisedResult.breachCount });
|
|
2299
|
+
emitEvaluation(ctx, trackEvent, {
|
|
2300
|
+
identifier,
|
|
2301
|
+
userAgent: ctx.headers?.get?.("user-agent") || ""
|
|
2302
|
+
});
|
|
2303
|
+
throw new APIError("BAD_REQUEST", {
|
|
2304
|
+
message: "This password has been found in data breaches. Please choose a different password.",
|
|
2305
|
+
code: "COMPROMISED_PASSWORD"
|
|
2306
|
+
});
|
|
2307
|
+
}
|
|
2295
2308
|
}
|
|
2296
2309
|
}
|
|
2297
2310
|
}
|
|
@@ -2304,6 +2317,10 @@ const sentinel = (options) => {
|
|
|
2304
2317
|
const visitorId = ctx.context.visitorId;
|
|
2305
2318
|
const identification = ctx.context.identification;
|
|
2306
2319
|
const body = ctx.body;
|
|
2320
|
+
emitEvaluation(ctx, trackEvent, {
|
|
2321
|
+
identifier: body?.email,
|
|
2322
|
+
userAgent: ctx.headers?.get?.("user-agent") || ""
|
|
2323
|
+
});
|
|
2307
2324
|
if (matchesAnyRoute(ctx.path, [routes.SIGN_IN_EMAIL, routes.SIGN_IN_EMAIL_OTP]) && ctx.context.returned instanceof Error && body?.email && body?.password && visitorId) {
|
|
2308
2325
|
const { email, password } = body;
|
|
2309
2326
|
const ip = identification?.ip || null;
|
|
@@ -2754,7 +2771,7 @@ const jwtValidateMiddleware = (options) => createAuthMiddleware(async (ctx) => {
|
|
|
2754
2771
|
});
|
|
2755
2772
|
//#endregion
|
|
2756
2773
|
//#region src/version.ts
|
|
2757
|
-
const PLUGIN_VERSION = "0.2.
|
|
2774
|
+
const PLUGIN_VERSION = "0.2.6";
|
|
2758
2775
|
//#endregion
|
|
2759
2776
|
//#region src/routes/auth/config.ts
|
|
2760
2777
|
const PLUGIN_OPTIONS_EXCLUDE_KEYS = { stripe: new Set(["stripeClient"]) };
|
|
@@ -3068,6 +3085,12 @@ function getScimEndpoint(baseUrl) {
|
|
|
3068
3085
|
function getSCIMPlugin(ctx) {
|
|
3069
3086
|
return ctx.context.getPlugin("scim");
|
|
3070
3087
|
}
|
|
3088
|
+
const DIRECTORY_SYNC_DUPLICATE_MESSAGE = "A directory sync connection with this provider ID already exists. Remove the existing connection or choose a different provider ID.";
|
|
3089
|
+
function isDuplicateDirectorySyncError(e) {
|
|
3090
|
+
if (e instanceof APIError$1) return e.status === "CONFLICT" || /duplicate|already exists|unique constraint/i.test(e.message ?? "");
|
|
3091
|
+
if (e instanceof Error) return /unique constraint|duplicate key|already exists|P2002/i.test(e.message);
|
|
3092
|
+
return false;
|
|
3093
|
+
}
|
|
3071
3094
|
const listOrganizationDirectories = (options) => {
|
|
3072
3095
|
return createAuthEndpoint("/dash/organization/:id/directories", {
|
|
3073
3096
|
method: "GET",
|
|
@@ -3118,14 +3141,22 @@ const createOrganizationDirectory = (options) => {
|
|
|
3118
3141
|
const scimPlugin = getSCIMPlugin(ctx);
|
|
3119
3142
|
if (!scimPlugin?.endpoints.generateSCIMToken || !scimPlugin?.options?.providerOwnership?.enabled) throw ctx.error("BAD_REQUEST", { message: "SCIM plugin is not enabled or does not support this feature" });
|
|
3120
3143
|
const { providerId, ownerUserId } = ctx.body;
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3144
|
+
let scimToken;
|
|
3145
|
+
try {
|
|
3146
|
+
scimToken = (await scimPlugin.endpoints.generateSCIMToken({
|
|
3147
|
+
body: {
|
|
3148
|
+
providerId,
|
|
3149
|
+
organizationId
|
|
3150
|
+
},
|
|
3151
|
+
context: await buildSyntheticSession(ctx, ownerUserId),
|
|
3152
|
+
headers: ctx.request?.headers ?? new Headers()
|
|
3153
|
+
})).scimToken;
|
|
3154
|
+
} catch (e) {
|
|
3155
|
+
if (isDuplicateDirectorySyncError(e)) throw ctx.error("BAD_REQUEST", { message: DIRECTORY_SYNC_DUPLICATE_MESSAGE });
|
|
3156
|
+
ctx.context.logger.error("[Dash] Failed to create directory sync provider (organizationId=%s, providerId=%s)", organizationId, providerId, e);
|
|
3157
|
+
if (e instanceof APIError$1) throw e;
|
|
3158
|
+
throw ctx.error("INTERNAL_SERVER_ERROR", { message: "Failed to create directory sync provider" });
|
|
3159
|
+
}
|
|
3129
3160
|
if (!scimToken) throw ctx.error("INTERNAL_SERVER_ERROR", { message: "Failed to create directory sync provider" });
|
|
3130
3161
|
return {
|
|
3131
3162
|
organizationId,
|
|
@@ -3234,8 +3265,11 @@ const getUserEvents = (options) => {
|
|
|
3234
3265
|
method: "GET",
|
|
3235
3266
|
use: [sessionMiddleware],
|
|
3236
3267
|
query: z$1.object({
|
|
3268
|
+
/** Maximum number of events to return (default: 50, max: 100) */
|
|
3237
3269
|
limit: z$1.number().or(z$1.string().transform(Number)).optional(),
|
|
3270
|
+
/** Number of events to skip for pagination (default: 0) */
|
|
3238
3271
|
offset: z$1.number().or(z$1.string().transform(Number)).optional(),
|
|
3272
|
+
/** Filter by event type (e.g., "user_signed_in") */
|
|
3239
3273
|
eventType: z$1.string().optional()
|
|
3240
3274
|
}).optional()
|
|
3241
3275
|
}, async (ctx) => {
|
|
@@ -3383,6 +3417,113 @@ const getAuditLogs = (options) => {
|
|
|
3383
3417
|
};
|
|
3384
3418
|
});
|
|
3385
3419
|
};
|
|
3420
|
+
const OWNER_ADMIN_ROLES = new Set(["owner", "admin"]);
|
|
3421
|
+
function isOwnerOrAdminRole(role) {
|
|
3422
|
+
return role !== void 0 && OWNER_ADMIN_ROLES.has(role);
|
|
3423
|
+
}
|
|
3424
|
+
/**
|
|
3425
|
+
* Get all audit logs the current user has access to
|
|
3426
|
+
* Queries can be restricted to a single organization, a single user, or all organizations the user has access to
|
|
3427
|
+
* @param options
|
|
3428
|
+
* @returns
|
|
3429
|
+
*/
|
|
3430
|
+
const getAllAuditLogs = (options) => {
|
|
3431
|
+
const $fetch = createFetch({
|
|
3432
|
+
baseURL: options.apiUrl,
|
|
3433
|
+
headers: { "x-api-key": options.apiKey }
|
|
3434
|
+
});
|
|
3435
|
+
const getUserOrganizationRole = async (adapter, orgId, userId) => {
|
|
3436
|
+
return (await adapter.findOne({
|
|
3437
|
+
model: "member",
|
|
3438
|
+
where: [{
|
|
3439
|
+
field: "organizationId",
|
|
3440
|
+
value: orgId
|
|
3441
|
+
}, {
|
|
3442
|
+
field: "userId",
|
|
3443
|
+
value: userId
|
|
3444
|
+
}],
|
|
3445
|
+
select: ["role"]
|
|
3446
|
+
}))?.role;
|
|
3447
|
+
};
|
|
3448
|
+
const listElevatedOrganizationIds = async (adapter, userId) => {
|
|
3449
|
+
const memberships = await adapter.findMany({
|
|
3450
|
+
model: "member",
|
|
3451
|
+
where: [{
|
|
3452
|
+
field: "userId",
|
|
3453
|
+
value: userId
|
|
3454
|
+
}],
|
|
3455
|
+
select: ["organizationId", "role"]
|
|
3456
|
+
});
|
|
3457
|
+
const ids = /* @__PURE__ */ new Set();
|
|
3458
|
+
for (const m of memberships) if (isOwnerOrAdminRole(m.role)) ids.add(m.organizationId);
|
|
3459
|
+
return [...ids];
|
|
3460
|
+
};
|
|
3461
|
+
return createAuthEndpoint("/events/all-audit-logs", {
|
|
3462
|
+
method: "GET",
|
|
3463
|
+
use: [sessionMiddleware],
|
|
3464
|
+
query: z$1.object({
|
|
3465
|
+
limit: z$1.number().or(z$1.string().transform(Number)).optional(),
|
|
3466
|
+
offset: z$1.number().or(z$1.string().transform(Number)).optional(),
|
|
3467
|
+
userId: z$1.string().optional(),
|
|
3468
|
+
organizationId: z$1.string().optional(),
|
|
3469
|
+
/** Filter by event type (e.g. `organization_member_added`) */
|
|
3470
|
+
eventType: z$1.string().optional(),
|
|
3471
|
+
/** Match `eventData.identifier` (organization-scoped actor identity) */
|
|
3472
|
+
identifier: z$1.string().optional()
|
|
3473
|
+
}).refine((q) => {
|
|
3474
|
+
const u = q.userId?.trim();
|
|
3475
|
+
const o = q.organizationId?.trim();
|
|
3476
|
+
return !(u && o);
|
|
3477
|
+
}, { message: "Provide at most one of userId and organizationId." }).optional()
|
|
3478
|
+
}, async (ctx) => {
|
|
3479
|
+
const session = ctx.context.session;
|
|
3480
|
+
if (!session?.user?.id) throw ctx.error("UNAUTHORIZED", { message: "You must be signed in to view activity logs" });
|
|
3481
|
+
if (!options.apiKey) throw ctx.error("INTERNAL_SERVER_ERROR", { message: "Events API is not configured" });
|
|
3482
|
+
const query = ctx.query ?? {};
|
|
3483
|
+
const adapter = ctx.context.adapter;
|
|
3484
|
+
const sessionUserId = session.user.id;
|
|
3485
|
+
const limit = Math.min(Math.max(1, query.limit ?? 50), 100);
|
|
3486
|
+
const offset = Math.max(0, query.offset ?? 0);
|
|
3487
|
+
const organizationId = query.organizationId?.trim();
|
|
3488
|
+
const userId = query.userId?.trim();
|
|
3489
|
+
const forbidAccess = () => {
|
|
3490
|
+
return ctx.error("FORBIDDEN", { message: "Only organization owners and admins can view activity logs." });
|
|
3491
|
+
};
|
|
3492
|
+
const elevatedOrgIds = await listElevatedOrganizationIds(adapter, sessionUserId);
|
|
3493
|
+
if (organizationId) {
|
|
3494
|
+
if (!isOwnerOrAdminRole(await getUserOrganizationRole(adapter, organizationId, sessionUserId))) throw forbidAccess();
|
|
3495
|
+
} else if (userId) {
|
|
3496
|
+
if (elevatedOrgIds.length === 0) throw forbidAccess();
|
|
3497
|
+
} else if (elevatedOrgIds.length === 0) throw forbidAccess();
|
|
3498
|
+
const apiQuery = {
|
|
3499
|
+
limit: limit.toString(),
|
|
3500
|
+
offset: offset.toString()
|
|
3501
|
+
};
|
|
3502
|
+
if (organizationId) apiQuery.organizationIds = organizationId;
|
|
3503
|
+
else if (userId) {
|
|
3504
|
+
apiQuery.userId = userId;
|
|
3505
|
+
apiQuery.organizationIds = elevatedOrgIds.join(",");
|
|
3506
|
+
} else apiQuery.organizationIds = elevatedOrgIds.join(",");
|
|
3507
|
+
const eventTypeFilter = query.eventType?.trim();
|
|
3508
|
+
const identifierFilter = query.identifier?.trim();
|
|
3509
|
+
if (eventTypeFilter) apiQuery.eventType = eventTypeFilter;
|
|
3510
|
+
if (identifierFilter) apiQuery.identifier = identifierFilter;
|
|
3511
|
+
const { data, error } = await $fetch("/events/activity", {
|
|
3512
|
+
method: "GET",
|
|
3513
|
+
query: apiQuery
|
|
3514
|
+
});
|
|
3515
|
+
if (error || !data) {
|
|
3516
|
+
ctx.context.logger.error("[Dash] Failed to fetch activity logs:", error);
|
|
3517
|
+
throw ctx.error("INTERNAL_SERVER_ERROR", { message: "Failed to fetch activity logs" });
|
|
3518
|
+
}
|
|
3519
|
+
return {
|
|
3520
|
+
events: data.events.map(transformEvent),
|
|
3521
|
+
total: data.total,
|
|
3522
|
+
limit: data.limit,
|
|
3523
|
+
offset: data.offset
|
|
3524
|
+
};
|
|
3525
|
+
});
|
|
3526
|
+
};
|
|
3386
3527
|
//#endregion
|
|
3387
3528
|
//#region src/routes/execute-adapter/index.ts
|
|
3388
3529
|
const whereClause = z.object({
|
|
@@ -6674,7 +6815,7 @@ const updateUser = (options) => createAuthEndpoint("/dash/update-user", {
|
|
|
6674
6815
|
const updateData = ctx.body;
|
|
6675
6816
|
const userId = ctx.context.payload?.userId;
|
|
6676
6817
|
if (!userId) throw new APIError("FORBIDDEN", { message: "Invalid payload" });
|
|
6677
|
-
const filteredData = Object.fromEntries(Object.entries(updateData).filter(([
|
|
6818
|
+
const filteredData = Object.fromEntries(Object.entries(updateData).filter(([, value]) => value !== void 0));
|
|
6678
6819
|
if (Object.keys(filteredData).length === 0) throw new APIError("BAD_REQUEST", { message: "No valid fields to update" });
|
|
6679
6820
|
const user = await ctx.context.internalAdapter.updateUser(userId, {
|
|
6680
6821
|
...filteredData,
|
|
@@ -7874,6 +8015,7 @@ const dash = (options) => {
|
|
|
7874
8015
|
dashGenerateBackupCodes: generateBackupCodes(opts),
|
|
7875
8016
|
getUserEvents: getUserEvents(opts),
|
|
7876
8017
|
getAuditLogs: getAuditLogs(opts),
|
|
8018
|
+
getAllAuditLogs: getAllAuditLogs(opts),
|
|
7877
8019
|
getEventTypes: getEventTypes(opts),
|
|
7878
8020
|
dashAcceptInvitation: acceptInvitation(opts),
|
|
7879
8021
|
dashCompleteInvitation: completeInvitation(opts),
|
package/dist/native.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as
|
|
1
|
+
import { a as DashGetAuditLogsInput, i as DashGetAllAuditLogsInput, n as DashAuditLogsResponse, o as dashClient, r as DashClientOptions, t as DashAuditLog } from "./dash-client-CVCHbtoB.mjs";
|
|
2
2
|
import { BetterAuthClientPlugin } from "better-auth";
|
|
3
3
|
|
|
4
4
|
//#region src/sentinel/native/client.d.ts
|
|
@@ -15,4 +15,4 @@ interface SentinelNativeClientOptions {
|
|
|
15
15
|
}
|
|
16
16
|
declare const sentinelNativeClient: (options?: SentinelNativeClientOptions) => BetterAuthClientPlugin;
|
|
17
17
|
//#endregion
|
|
18
|
-
export { type DashAuditLog, type DashAuditLogsResponse, type DashClientOptions, type DashGetAuditLogsInput, type SentinelNativeClientOptions, dashClient, sentinelNativeClient };
|
|
18
|
+
export { type DashAuditLog, type DashAuditLogsResponse, type DashClientOptions, type DashGetAllAuditLogsInput, type DashGetAuditLogsInput, type SentinelNativeClientOptions, dashClient, sentinelNativeClient };
|
package/dist/native.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as identify, c as dashClient, n as encodePoWSolution, o as bytesToHex, r as solvePoWChallenge, t as decodePoWChallenge } from "./pow-
|
|
1
|
+
import { a as identify, c as dashClient, n as encodePoWSolution, o as bytesToHex, r as solvePoWChallenge, t as decodePoWChallenge } from "./pow-CT9ehp8e.mjs";
|
|
2
2
|
import { env } from "@better-auth/core/env";
|
|
3
3
|
import { Dimensions, InteractionManager, PixelRatio, Platform } from "react-native";
|
|
4
4
|
//#region src/sentinel/native/components.ts
|
|
@@ -10,21 +10,39 @@ function resolveDashUserId(input, options) {
|
|
|
10
10
|
const dashClient = (options) => {
|
|
11
11
|
return {
|
|
12
12
|
id: "dash",
|
|
13
|
-
getActions: ($fetch) => ({ dash: {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
13
|
+
getActions: ($fetch) => ({ dash: {
|
|
14
|
+
getAuditLogs: async (input = {}) => {
|
|
15
|
+
const userId = resolveDashUserId(input, options);
|
|
16
|
+
return $fetch("/events/audit-logs", {
|
|
17
|
+
method: "GET",
|
|
18
|
+
query: {
|
|
19
|
+
limit: input.limit,
|
|
20
|
+
offset: input.offset,
|
|
21
|
+
organizationId: input.organizationId,
|
|
22
|
+
identifier: input.identifier,
|
|
23
|
+
eventType: input.eventType,
|
|
24
|
+
userId
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
},
|
|
28
|
+
getAllAuditLogs: async (input = {}) => {
|
|
29
|
+
return $fetch("/events/all-audit-logs", {
|
|
30
|
+
method: "GET",
|
|
31
|
+
query: {
|
|
32
|
+
limit: input.limit,
|
|
33
|
+
offset: input.offset,
|
|
34
|
+
organizationId: input.organizationId,
|
|
35
|
+
userId: input.userId,
|
|
36
|
+
eventType: input.eventType,
|
|
37
|
+
identifier: input.identifier
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
} }),
|
|
42
|
+
pathMethods: {
|
|
43
|
+
"/events/audit-logs": "GET",
|
|
44
|
+
"/events/all-audit-logs": "GET"
|
|
45
|
+
}
|
|
28
46
|
};
|
|
29
47
|
};
|
|
30
48
|
//#endregion
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@better-auth/infra",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"description": "Dashboard and analytics plugin for Better Auth",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.mjs",
|
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
"build": "tsdown",
|
|
12
12
|
"dev": "tsdown --watch --no-clean",
|
|
13
13
|
"typecheck": "tsc --noEmit",
|
|
14
|
-
"test": "bun test",
|
|
14
|
+
"test": "bun test --parallel",
|
|
15
15
|
"test:watch": "bun test --watch",
|
|
16
|
-
"coverage": "bun test --coverage"
|
|
16
|
+
"coverage": "bun test --coverage --parallel"
|
|
17
17
|
},
|
|
18
18
|
"exports": {
|
|
19
19
|
".": {
|
|
@@ -69,8 +69,8 @@
|
|
|
69
69
|
"better-auth": "catalog:",
|
|
70
70
|
"expo-crypto": "^14.0.2",
|
|
71
71
|
"happy-dom": "^20.9.0",
|
|
72
|
-
"msw": "^2.
|
|
73
|
-
"tsdown": "^0.21.
|
|
72
|
+
"msw": "^2.14.3",
|
|
73
|
+
"tsdown": "^0.21.10",
|
|
74
74
|
"typescript": "catalog:",
|
|
75
75
|
"zod": "catalog:"
|
|
76
76
|
},
|
|
@@ -78,7 +78,7 @@
|
|
|
78
78
|
"@better-fetch/fetch": "^1.1.21",
|
|
79
79
|
"better-call": "^1.3.3",
|
|
80
80
|
"jose": "^6.1.0",
|
|
81
|
-
"libphonenumber-js": "^1.12.
|
|
81
|
+
"libphonenumber-js": "^1.12.42"
|
|
82
82
|
},
|
|
83
83
|
"peerDependencies": {
|
|
84
84
|
"better-auth": ">=1.4.0",
|