@better-auth/infra 0.2.9 → 0.2.11
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.mjs +53 -44
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +208 -26
- package/dist/native.mjs +52 -43
- package/dist/{pow-retry-BTL4g3RP.mjs → pow-retry-D2RRftJr.mjs} +34 -1
- package/package.json +14 -13
package/dist/client.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import "./constants-CvriWQVc.mjs";
|
|
2
2
|
import { n as createKV } from "./fetch-DiAhoiKA.mjs";
|
|
3
|
-
import { a as
|
|
3
|
+
import { a as resolveSentinelClientIdentifyUrl, i as solvePoWChallenge, l as hash, n as decodePoWChallenge, o as generateRequestId, r as encodePoWSolution, s as identify, t as createPowRetryTimeout, u as dashClient } from "./pow-retry-D2RRftJr.mjs";
|
|
4
4
|
import { env } from "@better-auth/core/env";
|
|
5
5
|
//#region src/sentinel/fingerprint.ts
|
|
6
6
|
function murmurhash3(str, seed = 0) {
|
|
@@ -422,11 +422,15 @@ async function waitForIdentify(timeoutMs) {
|
|
|
422
422
|
}
|
|
423
423
|
//#endregion
|
|
424
424
|
//#region src/sentinel/client.ts
|
|
425
|
-
|
|
425
|
+
/** One recovery round after a consumed or superseded challenge (e.g. double-submit). */
|
|
426
|
+
const MAX_POW_CHALLENGE_ROUNDS = 2;
|
|
426
427
|
const sentinelClient = (options) => {
|
|
427
428
|
const autoSolve = options?.autoSolveChallenge !== false;
|
|
428
429
|
const $kv = createKV({
|
|
429
|
-
kvUrl:
|
|
430
|
+
kvUrl: resolveSentinelClientIdentifyUrl({
|
|
431
|
+
identifyUrl: options?.identifyUrl,
|
|
432
|
+
envKvUrl: env.BETTER_AUTH_KV_URL
|
|
433
|
+
}),
|
|
430
434
|
kvTimeout: options?.kvTimeout
|
|
431
435
|
});
|
|
432
436
|
if (typeof window !== "undefined") {
|
|
@@ -468,50 +472,55 @@ const sentinelClient = (options) => {
|
|
|
468
472
|
async onResponse(context) {
|
|
469
473
|
if (typeof window === "undefined") return context;
|
|
470
474
|
if (context.response.status !== 423 || !autoSolve) return context;
|
|
471
|
-
|
|
472
|
-
const
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
const
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
const
|
|
479
|
-
|
|
480
|
-
const
|
|
481
|
-
options?.
|
|
482
|
-
const
|
|
483
|
-
|
|
484
|
-
const retryHeaders = new Headers();
|
|
485
|
-
retryHeaders.set("X-PoW-Solution", encodePoWSolution(solution));
|
|
486
|
-
if (fingerprint) {
|
|
487
|
-
retryHeaders.set("X-Visitor-Id", fingerprint.visitorId);
|
|
488
|
-
retryHeaders.set("X-Request-Id", fingerprint.requestId);
|
|
489
|
-
}
|
|
490
|
-
retryHeaders.set("Content-Type", "application/json");
|
|
491
|
-
let body;
|
|
492
|
-
if (context.request && context.request.body) body = context.request._originalBody;
|
|
493
|
-
const req = context.request;
|
|
494
|
-
const powRetry = createPowRetryTimeout(req.timeout);
|
|
475
|
+
if (!context.response.headers.get("X-PoW-Challenge")) return context;
|
|
476
|
+
const req = context.request;
|
|
477
|
+
let response = context.response;
|
|
478
|
+
const url = response.url;
|
|
479
|
+
const body = req._originalBody;
|
|
480
|
+
for (let round = 0; round < MAX_POW_CHALLENGE_ROUNDS; round++) {
|
|
481
|
+
if (response.status !== 423) break;
|
|
482
|
+
const challengeHeader = response.headers.get("X-PoW-Challenge");
|
|
483
|
+
if (!challengeHeader) break;
|
|
484
|
+
const reason = response.headers.get("X-PoW-Reason") || "";
|
|
485
|
+
options?.onChallengeReceived?.(reason);
|
|
486
|
+
const challenge = decodePoWChallenge(challengeHeader);
|
|
487
|
+
if (!challenge) break;
|
|
495
488
|
try {
|
|
496
|
-
const
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
489
|
+
const startTime = Date.now();
|
|
490
|
+
const solution = await solvePoWChallenge(challenge);
|
|
491
|
+
options?.onChallengeSolved?.(Date.now() - startTime);
|
|
492
|
+
const fingerprint = await getFingerprint();
|
|
493
|
+
const retryHeaders = new Headers();
|
|
494
|
+
retryHeaders.set("X-PoW-Solution", encodePoWSolution(solution));
|
|
495
|
+
if (fingerprint) {
|
|
496
|
+
retryHeaders.set("X-Visitor-Id", fingerprint.visitorId);
|
|
497
|
+
retryHeaders.set("X-Request-Id", fingerprint.requestId);
|
|
498
|
+
}
|
|
499
|
+
retryHeaders.set("Content-Type", "application/json");
|
|
500
|
+
const powRetry = createPowRetryTimeout(req.timeout);
|
|
501
|
+
try {
|
|
502
|
+
response = await fetch(url, {
|
|
503
|
+
method: req.method || "POST",
|
|
504
|
+
headers: retryHeaders,
|
|
505
|
+
body,
|
|
506
|
+
credentials: "include",
|
|
507
|
+
...powRetry.signal ? { signal: powRetry.signal } : {}
|
|
508
|
+
});
|
|
509
|
+
} finally {
|
|
510
|
+
powRetry.cleanup?.();
|
|
511
|
+
}
|
|
512
|
+
if (response.status !== 423) break;
|
|
513
|
+
if (!response.headers.get("X-PoW-Challenge")) break;
|
|
514
|
+
} catch (error) {
|
|
515
|
+
console.error("[Sentinel] Failed to solve PoW challenge:", error);
|
|
516
|
+
options?.onChallengeFailed?.(error instanceof Error ? error : new Error(String(error)));
|
|
517
|
+
return context;
|
|
509
518
|
}
|
|
510
|
-
} catch (error) {
|
|
511
|
-
console.error("[Sentinel] Failed to solve PoW challenge:", error);
|
|
512
|
-
options?.onChallengeFailed?.(error instanceof Error ? error : new Error(String(error)));
|
|
513
|
-
return context;
|
|
514
519
|
}
|
|
520
|
+
return {
|
|
521
|
+
...context,
|
|
522
|
+
response
|
|
523
|
+
};
|
|
515
524
|
},
|
|
516
525
|
async onRequest(context) {
|
|
517
526
|
if (context.body) context._originalBody = context.body;
|
package/dist/index.d.mts
CHANGED
|
@@ -770,7 +770,7 @@ interface DashIdRow {
|
|
|
770
770
|
id: string;
|
|
771
771
|
}
|
|
772
772
|
//#endregion
|
|
773
|
-
//#region ../../node_modules/.bun/@better-auth+scim@1.6.
|
|
773
|
+
//#region ../../node_modules/.bun/@better-auth+scim@1.6.11+25e6a1339d5195dd/node_modules/@better-auth/scim/dist/index.d.mts
|
|
774
774
|
//#region src/types.d.ts
|
|
775
775
|
interface SCIMProvider {
|
|
776
776
|
id: string;
|
package/dist/index.mjs
CHANGED
|
@@ -2794,7 +2794,7 @@ const jwtValidateMiddleware = (options) => {
|
|
|
2794
2794
|
};
|
|
2795
2795
|
//#endregion
|
|
2796
2796
|
//#region src/version.ts
|
|
2797
|
-
const PLUGIN_VERSION = "0.2.
|
|
2797
|
+
const PLUGIN_VERSION = "0.2.11";
|
|
2798
2798
|
//#endregion
|
|
2799
2799
|
//#region src/routes/auth/config.ts
|
|
2800
2800
|
const PLUGIN_OPTIONS_EXCLUDE_KEYS = { stripe: new Set(["stripeClient"]) };
|
|
@@ -3825,6 +3825,23 @@ const checkUserExists = (options) => {
|
|
|
3825
3825
|
});
|
|
3826
3826
|
};
|
|
3827
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
|
|
3828
3845
|
//#region src/routes/organizations/invitations.ts
|
|
3829
3846
|
const listOrganizationInvitations = (options) => {
|
|
3830
3847
|
return createAuthEndpoint("/dash/organization/:id/invitations", {
|
|
@@ -3873,7 +3890,7 @@ const inviteMember = (options) => {
|
|
|
3873
3890
|
method: "POST",
|
|
3874
3891
|
body: z$1.object({
|
|
3875
3892
|
email: z$1.string(),
|
|
3876
|
-
role:
|
|
3893
|
+
role: organizationMemberRoleInputSchema,
|
|
3877
3894
|
invitedBy: z$1.string()
|
|
3878
3895
|
}),
|
|
3879
3896
|
use: [jwtMiddleware(options, z$1.object({
|
|
@@ -3883,6 +3900,7 @@ const inviteMember = (options) => {
|
|
|
3883
3900
|
}, async (ctx) => {
|
|
3884
3901
|
const { organizationId } = ctx.context.payload;
|
|
3885
3902
|
const organizationPlugin = requireOrganizationPlugin(ctx);
|
|
3903
|
+
validateOrganizationMemberRole(ctx, ctx.body.role, organizationPlugin.options);
|
|
3886
3904
|
if (!organizationPlugin.options?.sendInvitationEmail) throw ctx.error("BAD_REQUEST", { message: "Invitation email is not enabled" });
|
|
3887
3905
|
const invitedBy = await ctx.context.adapter.findOne({
|
|
3888
3906
|
model: "user",
|
|
@@ -4132,11 +4150,12 @@ const addMember = (options) => {
|
|
|
4132
4150
|
use: [jwtMiddleware(options, z$1.object({ organizationId: z$1.string() }))],
|
|
4133
4151
|
body: z$1.object({
|
|
4134
4152
|
userId: z$1.string(),
|
|
4135
|
-
role:
|
|
4153
|
+
role: organizationMemberRoleInputSchema
|
|
4136
4154
|
})
|
|
4137
4155
|
}, async (ctx) => {
|
|
4138
4156
|
const { organizationId } = ctx.context.payload;
|
|
4139
4157
|
const orgOptions = requireOrganizationPlugin(ctx).options || {};
|
|
4158
|
+
validateOrganizationMemberRole(ctx, ctx.body.role, orgOptions);
|
|
4140
4159
|
const organization = await ctx.context.adapter.findOne({
|
|
4141
4160
|
model: "organization",
|
|
4142
4161
|
where: [{
|
|
@@ -4265,11 +4284,12 @@ const updateMemberRole = (options) => {
|
|
|
4265
4284
|
use: [jwtMiddleware(options, z$1.object({ organizationId: z$1.string() }))],
|
|
4266
4285
|
body: z$1.object({
|
|
4267
4286
|
memberId: z$1.string(),
|
|
4268
|
-
role:
|
|
4287
|
+
role: organizationMemberRoleInputSchema
|
|
4269
4288
|
})
|
|
4270
4289
|
}, async (ctx) => {
|
|
4271
4290
|
const { organizationId } = ctx.context.payload;
|
|
4272
4291
|
const orgOptions = requireOrganizationPlugin(ctx).options || {};
|
|
4292
|
+
validateOrganizationMemberRole(ctx, ctx.body.role, orgOptions);
|
|
4273
4293
|
const existingMember = await ctx.context.adapter.findOne({
|
|
4274
4294
|
model: "member",
|
|
4275
4295
|
where: [{
|
|
@@ -5658,13 +5678,13 @@ const revokeManySessions = (options) => createAuthEndpoint("/dash/sessions/revok
|
|
|
5658
5678
|
});
|
|
5659
5679
|
});
|
|
5660
5680
|
//#endregion
|
|
5661
|
-
//#region
|
|
5681
|
+
//#region ../utils/dist/ip.mjs
|
|
5662
5682
|
/**
|
|
5663
5683
|
* SSRF (Server-Side Request Forgery) Protection
|
|
5664
5684
|
*
|
|
5665
|
-
*
|
|
5666
|
-
* networks. Covers IPv4 private ranges,
|
|
5667
|
-
* 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.
|
|
5668
5688
|
*/
|
|
5669
5689
|
function isPrivateIPv4(a, b) {
|
|
5670
5690
|
if (a === 10) return true;
|
|
@@ -5673,6 +5693,24 @@ function isPrivateIPv4(a, b) {
|
|
|
5673
5693
|
if (a === 127) return true;
|
|
5674
5694
|
if (a === 169 && b === 254) return true;
|
|
5675
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;
|
|
5676
5714
|
return false;
|
|
5677
5715
|
}
|
|
5678
5716
|
function parseIPv6(addr) {
|
|
@@ -5694,36 +5732,174 @@ function parseIPv6(addr) {
|
|
|
5694
5732
|
if (left.length !== 8) return null;
|
|
5695
5733
|
return left;
|
|
5696
5734
|
}
|
|
5735
|
+
/** Returns true if the hostname resolves to a private/reserved IP or is a local hostname. */
|
|
5697
5736
|
function isPrivateHost(hostname) {
|
|
5698
5737
|
if (hostname === "localhost" || hostname.endsWith(".local") || hostname.endsWith(".internal")) return true;
|
|
5699
5738
|
const bare = hostname.replace(/^\[|\]$/g, "");
|
|
5700
5739
|
const v4parts = bare.split(".").map(Number);
|
|
5701
5740
|
if (v4parts.length === 4 && v4parts.every((p) => !isNaN(p) && p >= 0 && p <= 255)) return isPrivateIPv4(v4parts[0], v4parts[1]);
|
|
5702
5741
|
const groups = parseIPv6(bare);
|
|
5703
|
-
if (groups && groups.length === 8)
|
|
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] === 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);
|
|
5709
|
-
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);
|
|
5710
|
-
if (groups[0] === 100 && groups[1] === 65435 && groups.slice(2, 6).every((g) => g === 0)) return isPrivateIPv4(groups[6] >> 8 & 255, groups[6] & 255);
|
|
5711
|
-
if (groups[0] === 8193 && groups[1] === 3512) return true;
|
|
5712
|
-
if (groups[0] === 256 && groups.slice(1, 4).every((g) => g === 0)) return true;
|
|
5713
|
-
}
|
|
5742
|
+
if (groups && groups.length === 8) return ipv6GroupsAreNonPublic(groups);
|
|
5714
5743
|
return false;
|
|
5715
5744
|
}
|
|
5716
|
-
|
|
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;
|
|
5717
5842
|
try {
|
|
5718
|
-
|
|
5719
|
-
|
|
5720
|
-
if (isPrivateHost(parsed.hostname)) return null;
|
|
5721
|
-
return parsed;
|
|
5843
|
+
if (trim.startsWith("/")) return new URL(trim, baseURL);
|
|
5844
|
+
return new URL(trim);
|
|
5722
5845
|
} catch {
|
|
5723
5846
|
return null;
|
|
5724
5847
|
}
|
|
5725
5848
|
}
|
|
5726
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
|
|
5727
5903
|
//#region src/routes/plugin-session.ts
|
|
5728
5904
|
/**
|
|
5729
5905
|
* Builds an in-memory session context for calling plugin endpoints
|
|
@@ -5836,13 +6012,19 @@ const oidcConfigSchema = z$1.object({
|
|
|
5836
6012
|
async function resolveSAMLConfig(samlConfig, providerId, baseURL, ctx) {
|
|
5837
6013
|
let idpMetadataXml = samlConfig.idpMetadata?.metadata;
|
|
5838
6014
|
if (!idpMetadataXml && samlConfig.idpMetadata?.metadataUrl) {
|
|
5839
|
-
const validatedMetadataUrl = parseAndValidateUrl(samlConfig.idpMetadata.metadataUrl);
|
|
6015
|
+
const validatedMetadataUrl = await parseAndValidateUrl(samlConfig.idpMetadata.metadataUrl);
|
|
5840
6016
|
if (!validatedMetadataUrl) throw ctx.error("BAD_REQUEST", { message: "Invalid or blocked IdP metadata URL" });
|
|
5841
6017
|
try {
|
|
5842
|
-
const metadataResponse = await
|
|
6018
|
+
const metadataResponse = await $outbound(validatedMetadataUrl.href, {
|
|
6019
|
+
method: "GET",
|
|
6020
|
+
headers: { Accept: "application/xml, text/xml" },
|
|
6021
|
+
timeout: 15e3
|
|
6022
|
+
});
|
|
5843
6023
|
if (!metadataResponse.ok) throw ctx.error("BAD_REQUEST", { message: `Failed to fetch IdP metadata from URL: ${metadataResponse.status} ${metadataResponse.statusText}` });
|
|
5844
6024
|
idpMetadataXml = await metadataResponse.text();
|
|
5845
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;
|
|
5846
6028
|
ctx.context.logger.error("[Dash] Failed to fetch IdP metadata from URL:", e);
|
|
5847
6029
|
throw ctx.error("BAD_REQUEST", { message: "Failed to fetch IdP metadata from URL" });
|
|
5848
6030
|
}
|
package/dist/native.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import "./constants-CvriWQVc.mjs";
|
|
2
2
|
import { n as createKV } from "./fetch-DiAhoiKA.mjs";
|
|
3
|
-
import {
|
|
3
|
+
import { a as resolveSentinelClientIdentifyUrl, c as bytesToHex, i as solvePoWChallenge, n as decodePoWChallenge, r as encodePoWSolution, s as identify, t as createPowRetryTimeout, u as dashClient } from "./pow-retry-D2RRftJr.mjs";
|
|
4
4
|
import { env } from "@better-auth/core/env";
|
|
5
5
|
import { Dimensions, InteractionManager, PixelRatio, Platform } from "react-native";
|
|
6
6
|
//#region src/sentinel/native/components.ts
|
|
@@ -190,7 +190,8 @@ async function getOrCreateVisitorId(storage) {
|
|
|
190
190
|
}
|
|
191
191
|
//#endregion
|
|
192
192
|
//#region src/sentinel/native/client.ts
|
|
193
|
-
|
|
193
|
+
/** One recovery round after a consumed or superseded challenge (e.g. double-submit). */
|
|
194
|
+
const MAX_POW_CHALLENGE_ROUNDS = 2;
|
|
194
195
|
function scheduleIdentify(send) {
|
|
195
196
|
const run = () => {
|
|
196
197
|
try {
|
|
@@ -213,7 +214,10 @@ const sentinelNativeClient = (options) => {
|
|
|
213
214
|
const autoSolve = options?.autoSolveChallenge !== false;
|
|
214
215
|
const runtime = createNativeFingerprintRuntime({
|
|
215
216
|
$kv: createKV({
|
|
216
|
-
kvUrl:
|
|
217
|
+
kvUrl: resolveSentinelClientIdentifyUrl({
|
|
218
|
+
identifyUrl: options?.identifyUrl,
|
|
219
|
+
envKvUrl: env.BETTER_AUTH_KV_URL
|
|
220
|
+
}),
|
|
217
221
|
kvTimeout: options?.kvTimeout
|
|
218
222
|
}),
|
|
219
223
|
getOrCreateVisitorId: () => getOrCreateVisitorId(options?.storage)
|
|
@@ -246,49 +250,54 @@ const sentinelNativeClient = (options) => {
|
|
|
246
250
|
hooks: {
|
|
247
251
|
async onResponse(context) {
|
|
248
252
|
if (context.response.status !== 423 || !autoSolve) return context;
|
|
249
|
-
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
const
|
|
259
|
-
options?.
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
const retryHeaders = new Headers();
|
|
263
|
-
retryHeaders.set("X-PoW-Solution", encodePoWSolution(solution));
|
|
264
|
-
if (fingerprint) {
|
|
265
|
-
retryHeaders.set("X-Visitor-Id", fingerprint.visitorId);
|
|
266
|
-
retryHeaders.set("X-Request-Id", fingerprint.requestId);
|
|
267
|
-
}
|
|
268
|
-
retryHeaders.set("Content-Type", "application/json");
|
|
269
|
-
let body;
|
|
270
|
-
if (context.request?.body) body = context.request._originalBody;
|
|
271
|
-
const req = context.request;
|
|
272
|
-
const powRetry = createPowRetryTimeout(req.timeout);
|
|
253
|
+
if (!context.response.headers.get("X-PoW-Challenge")) return context;
|
|
254
|
+
const req = context.request;
|
|
255
|
+
let response = context.response;
|
|
256
|
+
const url = response.url;
|
|
257
|
+
const body = req._originalBody;
|
|
258
|
+
for (let round = 0; round < MAX_POW_CHALLENGE_ROUNDS; round++) {
|
|
259
|
+
if (response.status !== 423) break;
|
|
260
|
+
const challengeHeader = response.headers.get("X-PoW-Challenge");
|
|
261
|
+
if (!challengeHeader) break;
|
|
262
|
+
const reason = response.headers.get("X-PoW-Reason") || "";
|
|
263
|
+
options?.onChallengeReceived?.(reason);
|
|
264
|
+
const challenge = decodePoWChallenge(challengeHeader);
|
|
265
|
+
if (!challenge) break;
|
|
273
266
|
try {
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
powRetry.
|
|
267
|
+
const startTime = Date.now();
|
|
268
|
+
const solution = await solvePoWChallenge(challenge);
|
|
269
|
+
options?.onChallengeSolved?.(Date.now() - startTime);
|
|
270
|
+
const fingerprint = await runtime.getFingerprint();
|
|
271
|
+
const retryHeaders = new Headers();
|
|
272
|
+
retryHeaders.set("X-PoW-Solution", encodePoWSolution(solution));
|
|
273
|
+
if (fingerprint) {
|
|
274
|
+
retryHeaders.set("X-Visitor-Id", fingerprint.visitorId);
|
|
275
|
+
retryHeaders.set("X-Request-Id", fingerprint.requestId);
|
|
276
|
+
}
|
|
277
|
+
retryHeaders.set("Content-Type", "application/json");
|
|
278
|
+
const powRetry = createPowRetryTimeout(req.timeout);
|
|
279
|
+
try {
|
|
280
|
+
response = await fetch(url, {
|
|
281
|
+
method: req.method || "POST",
|
|
282
|
+
headers: retryHeaders,
|
|
283
|
+
body,
|
|
284
|
+
...powRetry.signal ? { signal: powRetry.signal } : {}
|
|
285
|
+
});
|
|
286
|
+
} finally {
|
|
287
|
+
powRetry.cleanup?.();
|
|
288
|
+
}
|
|
289
|
+
if (response.status !== 423) break;
|
|
290
|
+
if (!response.headers.get("X-PoW-Challenge")) break;
|
|
291
|
+
} catch (err) {
|
|
292
|
+
console.error("[Sentinel native] Failed to solve PoW challenge:", err);
|
|
293
|
+
options?.onChallengeFailed?.(err instanceof Error ? err : new Error(String(err)));
|
|
294
|
+
return context;
|
|
286
295
|
}
|
|
287
|
-
} catch (err) {
|
|
288
|
-
console.error("[Sentinel native] Failed to solve PoW challenge:", err);
|
|
289
|
-
options?.onChallengeFailed?.(err instanceof Error ? err : new Error(String(err)));
|
|
290
|
-
return context;
|
|
291
296
|
}
|
|
297
|
+
return {
|
|
298
|
+
...context,
|
|
299
|
+
response
|
|
300
|
+
};
|
|
292
301
|
},
|
|
293
302
|
async onRequest(context) {
|
|
294
303
|
if (context.body) {
|
|
@@ -72,6 +72,39 @@ async function identify($kv, payload) {
|
|
|
72
72
|
});
|
|
73
73
|
}
|
|
74
74
|
//#endregion
|
|
75
|
+
//#region src/sentinel/identify-url.ts
|
|
76
|
+
const DEFAULT_IDENTIFY_URL = "https://kv.better-auth.com";
|
|
77
|
+
function normalizeIdentifyUrl(url) {
|
|
78
|
+
return url.replace(/\/$/, "");
|
|
79
|
+
}
|
|
80
|
+
/** True when the URL targets project-scoped ingestion, e.g. .../projects/{orgId}. */
|
|
81
|
+
function isProjectScopedIdentifyUrl(url) {
|
|
82
|
+
return /\/projects\/[^/]+$/.test(normalizeIdentifyUrl(url));
|
|
83
|
+
}
|
|
84
|
+
const DEFAULT_GLOBAL_INGESTION_WARNING = "[Sentinel] Default global identify ingestion is active but not recommended. Get your ingestion url from your project settings page (e.g. https://kv.better-auth.com/projects/{id}) and configure sentinelClient({ identifyUrl }) with it.";
|
|
85
|
+
const CONFIGURED_GLOBAL_INGESTION_WARNING = "[Sentinel] Global identify ingestion is configured but not recommended. Get your ingestion url from your project settings page (e.g. https://kv.better-auth.com/projects/{id}) and configure sentinelClient({ identifyUrl }) with it.";
|
|
86
|
+
function warnOnGlobalIdentifyIngestion(url, message) {
|
|
87
|
+
if (isProjectScopedIdentifyUrl(url)) return;
|
|
88
|
+
console.warn(message);
|
|
89
|
+
}
|
|
90
|
+
function resolveSentinelClientIdentifyUrl(options) {
|
|
91
|
+
const explicit = options?.identifyUrl?.trim();
|
|
92
|
+
if (explicit) {
|
|
93
|
+
const url = normalizeIdentifyUrl(explicit);
|
|
94
|
+
warnOnGlobalIdentifyIngestion(url, CONFIGURED_GLOBAL_INGESTION_WARNING);
|
|
95
|
+
return url;
|
|
96
|
+
}
|
|
97
|
+
const fromEnv = options?.envKvUrl?.trim();
|
|
98
|
+
if (fromEnv) {
|
|
99
|
+
const url = normalizeIdentifyUrl(fromEnv);
|
|
100
|
+
warnOnGlobalIdentifyIngestion(url, CONFIGURED_GLOBAL_INGESTION_WARNING);
|
|
101
|
+
return url;
|
|
102
|
+
}
|
|
103
|
+
const url = DEFAULT_IDENTIFY_URL;
|
|
104
|
+
warnOnGlobalIdentifyIngestion(url, DEFAULT_GLOBAL_INGESTION_WARNING);
|
|
105
|
+
return url;
|
|
106
|
+
}
|
|
107
|
+
//#endregion
|
|
75
108
|
//#region src/sentinel/pow.ts
|
|
76
109
|
function hasLeadingZeroBits(hash, bits) {
|
|
77
110
|
const fullHexChars = Math.floor(bits / 4);
|
|
@@ -148,4 +181,4 @@ function createPowRetryTimeout(timeoutMs) {
|
|
|
148
181
|
};
|
|
149
182
|
}
|
|
150
183
|
//#endregion
|
|
151
|
-
export {
|
|
184
|
+
export { resolveSentinelClientIdentifyUrl as a, bytesToHex as c, solvePoWChallenge as i, hash as l, decodePoWChallenge as n, generateRequestId as o, encodePoWSolution as r, identify as s, createPowRetryTimeout as t, dashClient as u };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@better-auth/infra",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.11",
|
|
4
4
|
"description": "Dashboard and analytics plugin for Better Auth",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.mjs",
|
|
@@ -61,24 +61,25 @@
|
|
|
61
61
|
},
|
|
62
62
|
"homepage": "https://better-auth.com",
|
|
63
63
|
"devDependencies": {
|
|
64
|
-
"@better-auth/scim": "
|
|
65
|
-
"@better-auth/sso": "
|
|
66
|
-
"@infra/mocks": "
|
|
67
|
-
"@
|
|
68
|
-
"@types/
|
|
69
|
-
"
|
|
64
|
+
"@better-auth/scim": "1.6.11",
|
|
65
|
+
"@better-auth/sso": "1.6.11",
|
|
66
|
+
"@infra/mocks": "0.0.0",
|
|
67
|
+
"@infra/utils": "0.0.0",
|
|
68
|
+
"@types/bun": "latest",
|
|
69
|
+
"@types/node": "^24.12.0",
|
|
70
|
+
"better-auth": "1.6.11",
|
|
70
71
|
"expo-crypto": "^14.0.2",
|
|
71
72
|
"happy-dom": "^20.9.0",
|
|
72
|
-
"msw": "
|
|
73
|
+
"msw": "^2.14.6",
|
|
73
74
|
"tsdown": "^0.22.0",
|
|
74
|
-
"typescript": "
|
|
75
|
-
"zod": "
|
|
75
|
+
"typescript": "^5.9.2",
|
|
76
|
+
"zod": "^4.3.6"
|
|
76
77
|
},
|
|
77
78
|
"dependencies": {
|
|
78
|
-
"@better-fetch/fetch": "
|
|
79
|
-
"better-call": "
|
|
79
|
+
"@better-fetch/fetch": "^1.1.21",
|
|
80
|
+
"better-call": "^1.3.2",
|
|
80
81
|
"jose": "^6.1.0",
|
|
81
|
-
"libphonenumber-js": "^1.13.
|
|
82
|
+
"libphonenumber-js": "^1.13.2"
|
|
82
83
|
},
|
|
83
84
|
"peerDependencies": {
|
|
84
85
|
"better-auth": ">=1.4.0",
|