@better-auth/infra 0.2.10 → 0.2.12
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 +48 -41
- package/dist/index.d.mts +49 -8
- package/dist/index.mjs +233 -180
- package/dist/native.mjs +47 -40
- package/package.json +2 -2
package/dist/client.mjs
CHANGED
|
@@ -422,6 +422,8 @@ async function waitForIdentify(timeoutMs) {
|
|
|
422
422
|
}
|
|
423
423
|
//#endregion
|
|
424
424
|
//#region src/sentinel/client.ts
|
|
425
|
+
/** One recovery round after a consumed or superseded challenge (e.g. double-submit). */
|
|
426
|
+
const MAX_POW_CHALLENGE_ROUNDS = 2;
|
|
425
427
|
const sentinelClient = (options) => {
|
|
426
428
|
const autoSolve = options?.autoSolveChallenge !== false;
|
|
427
429
|
const $kv = createKV({
|
|
@@ -470,50 +472,55 @@ const sentinelClient = (options) => {
|
|
|
470
472
|
async onResponse(context) {
|
|
471
473
|
if (typeof window === "undefined") return context;
|
|
472
474
|
if (context.response.status !== 423 || !autoSolve) return context;
|
|
473
|
-
|
|
474
|
-
const
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
const
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
const
|
|
481
|
-
|
|
482
|
-
const
|
|
483
|
-
options?.
|
|
484
|
-
const
|
|
485
|
-
|
|
486
|
-
const retryHeaders = new Headers();
|
|
487
|
-
retryHeaders.set("X-PoW-Solution", encodePoWSolution(solution));
|
|
488
|
-
if (fingerprint) {
|
|
489
|
-
retryHeaders.set("X-Visitor-Id", fingerprint.visitorId);
|
|
490
|
-
retryHeaders.set("X-Request-Id", fingerprint.requestId);
|
|
491
|
-
}
|
|
492
|
-
retryHeaders.set("Content-Type", "application/json");
|
|
493
|
-
let body;
|
|
494
|
-
if (context.request && context.request.body) body = context.request._originalBody;
|
|
495
|
-
const req = context.request;
|
|
496
|
-
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;
|
|
497
488
|
try {
|
|
498
|
-
const
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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;
|
|
511
518
|
}
|
|
512
|
-
} catch (error) {
|
|
513
|
-
console.error("[Sentinel] Failed to solve PoW challenge:", error);
|
|
514
|
-
options?.onChallengeFailed?.(error instanceof Error ? error : new Error(String(error)));
|
|
515
|
-
return context;
|
|
516
519
|
}
|
|
520
|
+
return {
|
|
521
|
+
...context,
|
|
522
|
+
response
|
|
523
|
+
};
|
|
517
524
|
},
|
|
518
525
|
async onRequest(context) {
|
|
519
526
|
if (context.body) context._originalBody = context.body;
|
package/dist/index.d.mts
CHANGED
|
@@ -117,6 +117,8 @@ interface SecurityVerdict {
|
|
|
117
117
|
challenge?: string;
|
|
118
118
|
reason?: string;
|
|
119
119
|
details?: Record<string, unknown>;
|
|
120
|
+
/** Set when the request included a valid, consumed PoW solution. */
|
|
121
|
+
powVerified?: boolean;
|
|
120
122
|
}
|
|
121
123
|
interface CredentialStuffingResult {
|
|
122
124
|
blocked: boolean;
|
|
@@ -265,7 +267,7 @@ declare const sentinel: (options?: SentinelOptions) => {
|
|
|
265
267
|
};
|
|
266
268
|
hooks: {
|
|
267
269
|
before: ({
|
|
268
|
-
matcher: (context: import("better-auth").HookEndpointContext) => boolean;
|
|
270
|
+
matcher: (context: Pick<import("better-auth").HookEndpointContext, "path">) => boolean;
|
|
269
271
|
handler: (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<{
|
|
270
272
|
context: {
|
|
271
273
|
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
|
|
@@ -437,7 +439,7 @@ declare const sentinel: (options?: SentinelOptions) => {
|
|
|
437
439
|
};
|
|
438
440
|
} | undefined>;
|
|
439
441
|
} | {
|
|
440
|
-
matcher: (
|
|
442
|
+
matcher: (ctx: import("better-auth").HookEndpointContext) => boolean;
|
|
441
443
|
handler: (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<void>;
|
|
442
444
|
})[];
|
|
443
445
|
after: {
|
|
@@ -555,6 +557,8 @@ type InfraEndpointContext = (GenericEndpointContext & {
|
|
|
555
557
|
identification?: Identification | null | undefined;
|
|
556
558
|
visitorId: string | null;
|
|
557
559
|
requestId: string | null;
|
|
560
|
+
ip: string | null;
|
|
561
|
+
untrustedVisitorId: string | null;
|
|
558
562
|
location: LocationData | undefined;
|
|
559
563
|
};
|
|
560
564
|
}) | undefined;
|
|
@@ -770,7 +774,7 @@ interface DashIdRow {
|
|
|
770
774
|
id: string;
|
|
771
775
|
}
|
|
772
776
|
//#endregion
|
|
773
|
-
//#region ../../node_modules/.bun/@better-auth+scim@1.6.11+
|
|
777
|
+
//#region ../../node_modules/.bun/@better-auth+scim@1.6.11+25e6a1339d5195dd/node_modules/@better-auth/scim/dist/index.d.mts
|
|
774
778
|
//#region src/types.d.ts
|
|
775
779
|
interface SCIMProvider {
|
|
776
780
|
id: string;
|
|
@@ -4523,7 +4527,6 @@ interface DashTwoFactorEnableResponse {
|
|
|
4523
4527
|
}
|
|
4524
4528
|
interface DashTwoFactorTotpViewResponse {
|
|
4525
4529
|
totpURI: string;
|
|
4526
|
-
secret: string;
|
|
4527
4530
|
}
|
|
4528
4531
|
interface DashTwoFactorBackupCodesResponse {
|
|
4529
4532
|
backupCodes: string[];
|
|
@@ -5771,12 +5774,50 @@ declare const dash: <O extends DashOptions>(options?: O) => {
|
|
|
5771
5774
|
body: import("zod").ZodObject<{
|
|
5772
5775
|
token: import("zod").ZodString;
|
|
5773
5776
|
password: import("zod").ZodOptional<import("zod").ZodString>;
|
|
5774
|
-
providerId: import("zod").ZodOptional<import("zod").ZodString>;
|
|
5775
|
-
providerAccountId: import("zod").ZodOptional<import("zod").ZodString>;
|
|
5776
|
-
accessToken: import("zod").ZodOptional<import("zod").ZodString>;
|
|
5777
|
-
refreshToken: import("zod").ZodOptional<import("zod").ZodString>;
|
|
5778
5777
|
}, import("zod/v4/core").$strip>;
|
|
5779
5778
|
}, DashCompleteInvitationResponse>;
|
|
5779
|
+
dashCompleteInvitationSocial: import("better-call").StrictEndpoint<"/dash/complete-invitation-social", {
|
|
5780
|
+
method: "GET";
|
|
5781
|
+
query: import("zod").ZodObject<{
|
|
5782
|
+
token: import("zod").ZodString;
|
|
5783
|
+
}, import("zod/v4/core").$strip>;
|
|
5784
|
+
use: ((inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<{
|
|
5785
|
+
session: {
|
|
5786
|
+
session: Record<string, any> & {
|
|
5787
|
+
id: string;
|
|
5788
|
+
createdAt: Date;
|
|
5789
|
+
updatedAt: Date;
|
|
5790
|
+
userId: string;
|
|
5791
|
+
expiresAt: Date;
|
|
5792
|
+
token: string;
|
|
5793
|
+
ipAddress?: string | null | undefined;
|
|
5794
|
+
userAgent?: string | null | undefined;
|
|
5795
|
+
};
|
|
5796
|
+
user: Record<string, any> & {
|
|
5797
|
+
id: string;
|
|
5798
|
+
createdAt: Date;
|
|
5799
|
+
updatedAt: Date;
|
|
5800
|
+
email: string;
|
|
5801
|
+
emailVerified: boolean;
|
|
5802
|
+
name: string;
|
|
5803
|
+
image?: string | null | undefined;
|
|
5804
|
+
};
|
|
5805
|
+
};
|
|
5806
|
+
}>)[];
|
|
5807
|
+
}, {
|
|
5808
|
+
status: ("OK" | "CREATED" | "ACCEPTED" | "NO_CONTENT" | "MULTIPLE_CHOICES" | "MOVED_PERMANENTLY" | "FOUND" | "SEE_OTHER" | "NOT_MODIFIED" | "TEMPORARY_REDIRECT" | "BAD_REQUEST" | "UNAUTHORIZED" | "PAYMENT_REQUIRED" | "FORBIDDEN" | "NOT_FOUND" | "METHOD_NOT_ALLOWED" | "NOT_ACCEPTABLE" | "PROXY_AUTHENTICATION_REQUIRED" | "REQUEST_TIMEOUT" | "CONFLICT" | "GONE" | "LENGTH_REQUIRED" | "PRECONDITION_FAILED" | "PAYLOAD_TOO_LARGE" | "URI_TOO_LONG" | "UNSUPPORTED_MEDIA_TYPE" | "RANGE_NOT_SATISFIABLE" | "EXPECTATION_FAILED" | "I'M_A_TEAPOT" | "MISDIRECTED_REQUEST" | "UNPROCESSABLE_ENTITY" | "LOCKED" | "FAILED_DEPENDENCY" | "TOO_EARLY" | "UPGRADE_REQUIRED" | "PRECONDITION_REQUIRED" | "TOO_MANY_REQUESTS" | "REQUEST_HEADER_FIELDS_TOO_LARGE" | "UNAVAILABLE_FOR_LEGAL_REASONS" | "INTERNAL_SERVER_ERROR" | "NOT_IMPLEMENTED" | "BAD_GATEWAY" | "SERVICE_UNAVAILABLE" | "GATEWAY_TIMEOUT" | "HTTP_VERSION_NOT_SUPPORTED" | "VARIANT_ALSO_NEGOTIATES" | "INSUFFICIENT_STORAGE" | "LOOP_DETECTED" | "NOT_EXTENDED" | "NETWORK_AUTHENTICATION_REQUIRED") | import("better-call").Status;
|
|
5809
|
+
body: ({
|
|
5810
|
+
message?: string;
|
|
5811
|
+
code?: string;
|
|
5812
|
+
cause?: unknown;
|
|
5813
|
+
} & Record<string, any>) | undefined;
|
|
5814
|
+
headers: HeadersInit;
|
|
5815
|
+
statusCode: number;
|
|
5816
|
+
name: string;
|
|
5817
|
+
message: string;
|
|
5818
|
+
stack?: string;
|
|
5819
|
+
cause?: unknown;
|
|
5820
|
+
}>;
|
|
5780
5821
|
dashCheckUserExists: import("better-call").StrictEndpoint<"/dash/check-user-exists", {
|
|
5781
5822
|
method: "POST";
|
|
5782
5823
|
use: ((inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<{
|
package/dist/index.mjs
CHANGED
|
@@ -942,45 +942,67 @@ function extractIdentificationHeaders(request) {
|
|
|
942
942
|
};
|
|
943
943
|
}
|
|
944
944
|
/**
|
|
945
|
+
* Visitor ID for security enforcement from a loaded identification record —
|
|
946
|
+
* not a client-supplied X-Visitor-Id header alone.
|
|
947
|
+
*/
|
|
948
|
+
function resolveSecurityVisitorId(headerVisitorId, identification) {
|
|
949
|
+
const identified = identification?.visitorId ?? null;
|
|
950
|
+
if (!identified) return null;
|
|
951
|
+
if (headerVisitorId && headerVisitorId !== identified) logger.warn("[Sentinel] X-Visitor-Id does not match identification; using identification visitorId for security checks.");
|
|
952
|
+
return identified;
|
|
953
|
+
}
|
|
954
|
+
/** IP for security checks: prefer identify record, then request-derived fallback. */
|
|
955
|
+
function resolveSecurityIp(identification, location) {
|
|
956
|
+
return identification?.ip ?? location?.ipAddress ?? null;
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* Visitor id for observability and failed-login tracking when identification
|
|
960
|
+
* is unavailable. Prefer bound visitor id, then a stable IP bucket; header is
|
|
961
|
+
* legacy fallback only — not used for enforcement.
|
|
962
|
+
*/
|
|
963
|
+
function resolveUntrustedVisitorId(visitorId, ip, headerVisitorId) {
|
|
964
|
+
if (visitorId) return visitorId;
|
|
965
|
+
if (ip) return `ip:${ip}`;
|
|
966
|
+
return headerVisitorId;
|
|
967
|
+
}
|
|
968
|
+
/**
|
|
945
969
|
* Early middleware that loads identification data.
|
|
946
970
|
*
|
|
947
971
|
* @param $kv — KV client from {@link createKV} for the same plugin options (one instance per plugin).
|
|
948
972
|
*/
|
|
949
973
|
function createIdentificationMiddleware($kv) {
|
|
950
974
|
return createAuthMiddleware(async (ctx) => {
|
|
951
|
-
const { visitorId, requestId: headerRequestId } = extractIdentificationHeaders(ctx.request);
|
|
975
|
+
const { visitorId: headerVisitorId, requestId: headerRequestId } = extractIdentificationHeaders(ctx.request);
|
|
952
976
|
const requestId = headerRequestId ?? ctx.getCookie("__infra-rid") ?? null;
|
|
953
|
-
ctx.context.visitorId = visitorId;
|
|
954
977
|
ctx.context.requestId = requestId;
|
|
955
978
|
if (requestId) {
|
|
956
979
|
if (ctx.context.identification === void 0) ctx.context.identification = await getIdentification(requestId, $kv) ?? null;
|
|
957
980
|
} else ctx.context.identification = null;
|
|
958
|
-
const ipConfig = ctx.context.options?.advanced?.ipAddress;
|
|
959
|
-
if (ipConfig?.disableIpTracking === true) {
|
|
960
|
-
ctx.context.location = void 0;
|
|
961
|
-
return;
|
|
962
|
-
}
|
|
963
981
|
const identification = ctx.context.identification;
|
|
964
|
-
|
|
982
|
+
const visitorId = resolveSecurityVisitorId(headerVisitorId, identification);
|
|
983
|
+
ctx.context.visitorId = visitorId;
|
|
984
|
+
const ipConfig = ctx.context.options?.advanced?.ipAddress;
|
|
985
|
+
let location;
|
|
986
|
+
if (ipConfig?.disableIpTracking === true) location = void 0;
|
|
987
|
+
else if (requestId && identification) {
|
|
965
988
|
const loc = getLocation(identification);
|
|
966
|
-
|
|
989
|
+
location = {
|
|
967
990
|
ipAddress: identification.ip || void 0,
|
|
968
991
|
city: loc?.city || void 0,
|
|
969
992
|
country: loc?.country?.name || void 0,
|
|
970
993
|
countryCode: loc?.country?.code || void 0
|
|
971
994
|
};
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
if (ipAddress) {
|
|
976
|
-
const countryCode = getCountryCodeFromRequest(ctx.request);
|
|
977
|
-
ctx.context.location = {
|
|
995
|
+
} else {
|
|
996
|
+
const ipAddress = getClientIpFromRequest(ctx.request, ipConfig?.ipAddressHeaders || null);
|
|
997
|
+
if (ipAddress) location = {
|
|
978
998
|
ipAddress,
|
|
979
|
-
countryCode
|
|
999
|
+
countryCode: getCountryCodeFromRequest(ctx.request)
|
|
980
1000
|
};
|
|
981
|
-
|
|
1001
|
+
else location = void 0;
|
|
982
1002
|
}
|
|
983
|
-
ctx.context.location =
|
|
1003
|
+
ctx.context.location = location;
|
|
1004
|
+
ctx.context.ip = resolveSecurityIp(identification, location);
|
|
1005
|
+
ctx.context.untrustedVisitorId = resolveUntrustedVisitorId(visitorId, ctx.context.ip, headerVisitorId);
|
|
984
1006
|
});
|
|
985
1007
|
}
|
|
986
1008
|
/**
|
|
@@ -1024,7 +1046,13 @@ function getEvaluation(ctx) {
|
|
|
1024
1046
|
function recordCheck(ctx, name) {
|
|
1025
1047
|
getEvaluation(ctx).checks.add(name);
|
|
1026
1048
|
}
|
|
1027
|
-
/**
|
|
1049
|
+
/**
|
|
1050
|
+
* Call immediately before throwing so the buffer reflects the final outcome.
|
|
1051
|
+
*
|
|
1052
|
+
* `details.reason`, when set, is surfaced as the top-level dashboard reason
|
|
1053
|
+
* (see `emitEvaluation`) — so it must be a renderable reason, not a free-form
|
|
1054
|
+
* value. Put internal sub-states under a different key (e.g. `phase`).
|
|
1055
|
+
*/
|
|
1028
1056
|
function setOutcome(ctx, outcome, triggeredBy, details) {
|
|
1029
1057
|
const ev = getEvaluation(ctx);
|
|
1030
1058
|
ev.outcome = outcome;
|
|
@@ -1052,6 +1080,7 @@ function emitEvaluation(ctx, trackEvent, options = {}) {
|
|
|
1052
1080
|
outcome: ev.outcome,
|
|
1053
1081
|
evaluatedChecks: [...ev.checks].sort(),
|
|
1054
1082
|
triggeredBy: ev.triggeredBy,
|
|
1083
|
+
reason: ev.details?.reason ?? ev.triggeredBy,
|
|
1055
1084
|
identifier: options.identifier,
|
|
1056
1085
|
path,
|
|
1057
1086
|
userAgent: options.userAgent,
|
|
@@ -1171,9 +1200,11 @@ function createSecurityClient(conn, $api, options, onSecurityEvent) {
|
|
|
1171
1200
|
logger.error("[Dash] Clear failed attempts error:", error);
|
|
1172
1201
|
}
|
|
1173
1202
|
},
|
|
1174
|
-
async isBlocked(visitorId) {
|
|
1203
|
+
async isBlocked(visitorId, ip) {
|
|
1175
1204
|
try {
|
|
1176
|
-
|
|
1205
|
+
const params = new URLSearchParams({ visitorId });
|
|
1206
|
+
if (ip) params.set("ip", ip);
|
|
1207
|
+
return (await $api(`/security/is-blocked?${params.toString()}`, { method: "GET" })).blocked ?? false;
|
|
1177
1208
|
} catch (error) {
|
|
1178
1209
|
logger.warn("[Dash] Security is-blocked check failed:", error);
|
|
1179
1210
|
return false;
|
|
@@ -1483,52 +1514,47 @@ function createSecurityClient(conn, $api, options, onSecurityEvent) {
|
|
|
1483
1514
|
}
|
|
1484
1515
|
//#endregion
|
|
1485
1516
|
//#region src/validation/matchers.ts
|
|
1486
|
-
|
|
1517
|
+
/**
|
|
1518
|
+
* Routes where a new or changed email address enters the system. Deliverability
|
|
1519
|
+
* and disposable validation runs here so invalid addresses are caught at the
|
|
1520
|
+
* source.
|
|
1521
|
+
*/
|
|
1522
|
+
const registrationPaths = [
|
|
1487
1523
|
"/sign-up/email",
|
|
1488
|
-
"/email-otp/verify-email",
|
|
1489
|
-
"/sign-in/email-otp",
|
|
1490
|
-
"/sign-in/magic-link",
|
|
1491
|
-
"/sign-in/email",
|
|
1492
|
-
"/forget-password/email-otp",
|
|
1493
|
-
"/email-otp/reset-password",
|
|
1494
|
-
"/email-otp/create-verification-otp",
|
|
1495
|
-
"/email-otp/get-verification-otp",
|
|
1496
|
-
"/email-otp/send-verification-otp",
|
|
1497
|
-
"/forget-password",
|
|
1498
|
-
"/request-password-reset",
|
|
1499
|
-
"/send-verification-email",
|
|
1500
1524
|
"/change-email",
|
|
1501
1525
|
"/organization/invite-member",
|
|
1502
1526
|
"/dash/organization/invite-member",
|
|
1503
1527
|
"/dash/create-user",
|
|
1504
1528
|
"/dash/update-user"
|
|
1505
|
-
]
|
|
1529
|
+
];
|
|
1506
1530
|
/**
|
|
1507
|
-
*
|
|
1508
|
-
*
|
|
1509
|
-
*
|
|
1510
|
-
* '/sign-in/email-otp',
|
|
1511
|
-
* '/sign-in/magic-link',
|
|
1512
|
-
* '/sign-in/email',
|
|
1513
|
-
* '/forget-password/email-otp',
|
|
1514
|
-
* '/email-otp/reset-password',
|
|
1515
|
-
* '/email-otp/create-verification-otp',
|
|
1516
|
-
* '/email-otp/get-verification-otp',
|
|
1517
|
-
* '/email-otp/send-verification-otp',
|
|
1518
|
-
* '/forget-password',
|
|
1519
|
-
* '/request-password-reset',
|
|
1520
|
-
* '/send-verification-email',
|
|
1521
|
-
* '/change-email',
|
|
1522
|
-
* '/organization/invite-member',
|
|
1523
|
-
* '/dash/organization/invite-member',
|
|
1524
|
-
* '/dash/create-user',
|
|
1525
|
-
* '/dash/update-user'
|
|
1526
|
-
* ]`.
|
|
1527
|
-
* @param context Request context
|
|
1528
|
-
* @param context.path Request path
|
|
1529
|
-
* @returns boolean
|
|
1531
|
+
* Authentication and account-recovery routes. These act on an already-known
|
|
1532
|
+
* address, so only the syntax check runs — a deliverability false-positive must
|
|
1533
|
+
* never lock out an existing user (better-auth#9803).
|
|
1530
1534
|
*/
|
|
1535
|
+
const authPaths = [
|
|
1536
|
+
"/sign-in/email",
|
|
1537
|
+
"/sign-in/email-otp",
|
|
1538
|
+
"/sign-in/magic-link",
|
|
1539
|
+
"/forget-password",
|
|
1540
|
+
"/forget-password/email-otp",
|
|
1541
|
+
"/request-password-reset",
|
|
1542
|
+
"/send-verification-email",
|
|
1543
|
+
"/email-otp/verify-email",
|
|
1544
|
+
"/email-otp/reset-password",
|
|
1545
|
+
"/email-otp/create-verification-otp",
|
|
1546
|
+
"/email-otp/get-verification-otp",
|
|
1547
|
+
"/email-otp/send-verification-otp"
|
|
1548
|
+
];
|
|
1549
|
+
const registration = new Set(registrationPaths);
|
|
1550
|
+
const all = new Set([...registrationPaths, ...authPaths]);
|
|
1551
|
+
/** Path carries an email we hook for normalization + syntax validation. */
|
|
1531
1552
|
const allEmail = ({ path }) => !!path && all.has(path);
|
|
1553
|
+
/**
|
|
1554
|
+
* Path introduces a new or changed address — run full deliverability/disposable
|
|
1555
|
+
* validation. Authentication routes are intentionally excluded.
|
|
1556
|
+
*/
|
|
1557
|
+
const registrationEmail = ({ path }) => !!path && registration.has(path);
|
|
1532
1558
|
//#endregion
|
|
1533
1559
|
//#region src/validation/email.ts
|
|
1534
1560
|
/**
|
|
@@ -1624,7 +1650,7 @@ async validate(email, checkMx = true) {
|
|
|
1624
1650
|
policy
|
|
1625
1651
|
};
|
|
1626
1652
|
try {
|
|
1627
|
-
const { data } = await $kv("/email/validate", {
|
|
1653
|
+
const { data, error } = await $kv("/email/validate", {
|
|
1628
1654
|
method: "POST",
|
|
1629
1655
|
body: {
|
|
1630
1656
|
email: trimmed,
|
|
@@ -1632,11 +1658,15 @@ async validate(email, checkMx = true) {
|
|
|
1632
1658
|
strictness: policy.strictness
|
|
1633
1659
|
}
|
|
1634
1660
|
});
|
|
1661
|
+
if (error || !data) {
|
|
1662
|
+
logger.warn("[Dash] Email validation unavailable, allowing email:", error);
|
|
1663
|
+
return {
|
|
1664
|
+
valid: true,
|
|
1665
|
+
policy
|
|
1666
|
+
};
|
|
1667
|
+
}
|
|
1635
1668
|
return {
|
|
1636
|
-
...data
|
|
1637
|
-
valid: false,
|
|
1638
|
-
reason: "invalid_format"
|
|
1639
|
-
},
|
|
1669
|
+
...data,
|
|
1640
1670
|
policy
|
|
1641
1671
|
};
|
|
1642
1672
|
} catch (error) {
|
|
@@ -1649,6 +1679,26 @@ async validate(email, checkMx = true) {
|
|
|
1649
1679
|
} };
|
|
1650
1680
|
}
|
|
1651
1681
|
/**
|
|
1682
|
+
* Map a raw validation result to a single dashboard reason and user-facing
|
|
1683
|
+
* message. One source so the recorded reason and the thrown message can't drift,
|
|
1684
|
+
* and a block always carries a populated reason (better-auth#9803).
|
|
1685
|
+
*/
|
|
1686
|
+
function resolveEmailBlock(result) {
|
|
1687
|
+
const raw = result.reason ?? "";
|
|
1688
|
+
if (result.disposable || raw === "blocklist" || raw.startsWith("known_disposable")) return {
|
|
1689
|
+
reason: "disposable_email",
|
|
1690
|
+
message: "Disposable email addresses are not allowed"
|
|
1691
|
+
};
|
|
1692
|
+
if (raw === "no_mx_records") return {
|
|
1693
|
+
reason: "no_mx_records",
|
|
1694
|
+
message: "This email domain cannot receive emails"
|
|
1695
|
+
};
|
|
1696
|
+
return {
|
|
1697
|
+
reason: "invalid_email",
|
|
1698
|
+
message: "This email address appears to be invalid"
|
|
1699
|
+
};
|
|
1700
|
+
}
|
|
1701
|
+
/**
|
|
1652
1702
|
* Basic local email format validation (fallback)
|
|
1653
1703
|
*/
|
|
1654
1704
|
function isValidEmailFormatLocal(email) {
|
|
@@ -1714,14 +1764,15 @@ function createEmailValidationHook(validator, trackEvent) {
|
|
|
1714
1764
|
if (!isValidEmailFormatLocal(trimmed)) {
|
|
1715
1765
|
if (trackEvent) {
|
|
1716
1766
|
setOutcome(ctx, "blocked", "email_validity", {
|
|
1717
|
-
reason: "
|
|
1767
|
+
reason: "invalid_email",
|
|
1768
|
+
rawReason: "invalid_format",
|
|
1718
1769
|
email: trimmed
|
|
1719
1770
|
});
|
|
1720
1771
|
emitEvaluation(ctx, trackEvent, { identifier: trimmed });
|
|
1721
1772
|
}
|
|
1722
1773
|
throw new APIError$1("BAD_REQUEST", { message: "Invalid email" });
|
|
1723
1774
|
}
|
|
1724
|
-
if (validator) {
|
|
1775
|
+
if (validator && registrationEmail(ctx)) {
|
|
1725
1776
|
const result = await validator.validate(trimmed);
|
|
1726
1777
|
const policy = result.policy;
|
|
1727
1778
|
if (!policy?.enabled) return;
|
|
@@ -1732,10 +1783,11 @@ function createEmailValidationHook(validator, trackEvent) {
|
|
|
1732
1783
|
const action = policy.action;
|
|
1733
1784
|
if (!result.valid) {
|
|
1734
1785
|
if (action === "allow") return;
|
|
1735
|
-
const
|
|
1786
|
+
const { reason, message } = resolveEmailBlock(result);
|
|
1736
1787
|
if (trackEvent) {
|
|
1737
1788
|
setOutcome(ctx, "blocked", "email_validity", {
|
|
1738
|
-
reason
|
|
1789
|
+
reason,
|
|
1790
|
+
rawReason: result.reason,
|
|
1739
1791
|
confidence: result.confidence,
|
|
1740
1792
|
email: trimmed
|
|
1741
1793
|
});
|
|
@@ -2006,6 +2058,7 @@ function throwChallengeError(challenge, reason, message = "Please complete a sec
|
|
|
2006
2058
|
}
|
|
2007
2059
|
/** Emits the `security_check` event inline before throwing — the after-hook won't run on throw. */
|
|
2008
2060
|
async function handleSecurityVerdict(verdict, hookCtx, checkCtx, trackEvent, securityService) {
|
|
2061
|
+
if (verdict.powVerified) recordCheck(hookCtx, "pow_challenge");
|
|
2009
2062
|
if (verdict.action === "allow") return;
|
|
2010
2063
|
const reason = verdict.reason || "unknown";
|
|
2011
2064
|
if (verdict.action === "challenge" && checkCtx.visitorId) {
|
|
@@ -2040,15 +2093,15 @@ async function handleSecurityVerdict(verdict, hookCtx, checkCtx, trackEvent, sec
|
|
|
2040
2093
|
* Single API call from the plugin; server runs all configured rules and returns
|
|
2041
2094
|
* one verdict — counts as one billable check.
|
|
2042
2095
|
*/
|
|
2043
|
-
async function runSecurityChecks(hookCtx, checkCtx, securityService, trackEvent,
|
|
2044
|
-
if (powVerified) return;
|
|
2096
|
+
async function runSecurityChecks(hookCtx, checkCtx, securityService, trackEvent, powSolution) {
|
|
2045
2097
|
recordCheck(hookCtx, "server_security_check");
|
|
2046
2098
|
await handleSecurityVerdict(await securityService.checkSecurity({
|
|
2047
2099
|
visitorId: checkCtx.visitorId,
|
|
2048
2100
|
requestId: checkCtx.identification?.requestId || null,
|
|
2049
|
-
ip: checkCtx.
|
|
2101
|
+
ip: checkCtx.ip,
|
|
2050
2102
|
path: checkCtx.path,
|
|
2051
|
-
identifier: checkCtx.identifier
|
|
2103
|
+
identifier: checkCtx.identifier,
|
|
2104
|
+
powSolution: powSolution ?? void 0
|
|
2052
2105
|
}), hookCtx, checkCtx, trackEvent, securityService);
|
|
2053
2106
|
}
|
|
2054
2107
|
//#endregion
|
|
@@ -2228,16 +2281,6 @@ const sentinel = (options) => {
|
|
|
2228
2281
|
matcher: (ctx) => ctx.request?.method !== "GET",
|
|
2229
2282
|
handler: createIdentificationMiddleware($kv)
|
|
2230
2283
|
},
|
|
2231
|
-
{
|
|
2232
|
-
matcher: (ctx) => ctx.request?.method !== "GET",
|
|
2233
|
-
handler: createAuthMiddleware(async (ctx) => {
|
|
2234
|
-
const visitorId = ctx.context.visitorId;
|
|
2235
|
-
const powSolution = ctx.headers?.get?.("X-PoW-Solution");
|
|
2236
|
-
if (visitorId && powSolution) {
|
|
2237
|
-
if ((await securityService.verifyPoWSolution(visitorId, powSolution)).valid) ctx.context.powVerified = true;
|
|
2238
|
-
}
|
|
2239
|
-
})
|
|
2240
|
-
},
|
|
2241
2284
|
...emailHooks.before,
|
|
2242
2285
|
...phoneValidationHooks.before,
|
|
2243
2286
|
{
|
|
@@ -2245,6 +2288,7 @@ const sentinel = (options) => {
|
|
|
2245
2288
|
handler: createAuthMiddleware(async (ctx) => {
|
|
2246
2289
|
const visitorId = ctx.context.visitorId;
|
|
2247
2290
|
const identification = ctx.context.identification;
|
|
2291
|
+
const ip = ctx.context.ip;
|
|
2248
2292
|
const isSignIn = matchesAnyRoute(ctx.path, [
|
|
2249
2293
|
routes.SIGN_IN_EMAIL,
|
|
2250
2294
|
routes.SIGN_IN_USERNAME,
|
|
@@ -2275,14 +2319,13 @@ const sentinel = (options) => {
|
|
|
2275
2319
|
if (!(isSignIn || isSignUp || isPasswordReset || isTwoFactor || isOtpSend || isMagicLinkVerify || isOrgCreate || isSensitiveAction)) return;
|
|
2276
2320
|
const requestBody = ctx.body;
|
|
2277
2321
|
const identifier = requestBody?.email || requestBody?.phone || requestBody?.username || void 0;
|
|
2278
|
-
const
|
|
2279
|
-
if (visitorId
|
|
2280
|
-
if (visitorId) {
|
|
2322
|
+
const powSolution = ctx.headers?.get?.("X-PoW-Solution");
|
|
2323
|
+
if (visitorId || ip) {
|
|
2281
2324
|
recordCheck(ctx, "credential_stuffing");
|
|
2282
|
-
if (await securityService.isBlocked(visitorId)) {
|
|
2325
|
+
if (await securityService.isBlocked(visitorId ?? "", ip)) {
|
|
2283
2326
|
setOutcome(ctx, "blocked", "credential_stuffing", {
|
|
2284
|
-
|
|
2285
|
-
visitorId,
|
|
2327
|
+
phase: "cooldown_active",
|
|
2328
|
+
visitorId: ctx.context.untrustedVisitorId ?? void 0,
|
|
2286
2329
|
confidence: 1
|
|
2287
2330
|
});
|
|
2288
2331
|
emitEvaluation(ctx, trackEvent, {
|
|
@@ -2297,8 +2340,9 @@ const sentinel = (options) => {
|
|
|
2297
2340
|
identifier,
|
|
2298
2341
|
visitorId,
|
|
2299
2342
|
identification,
|
|
2343
|
+
ip: ctx.context.ip,
|
|
2300
2344
|
userAgent: ctx.headers?.get?.("user-agent") || ""
|
|
2301
|
-
}, securityService, trackEvent,
|
|
2345
|
+
}, securityService, trackEvent, powSolution);
|
|
2302
2346
|
if (matchesAnyRoute(ctx.path, [
|
|
2303
2347
|
routes.SIGN_UP_EMAIL,
|
|
2304
2348
|
routes.CHANGE_PASSWORD,
|
|
@@ -2331,22 +2375,21 @@ const sentinel = (options) => {
|
|
|
2331
2375
|
after: [{
|
|
2332
2376
|
matcher: (ctx) => ctx.request?.method !== "GET",
|
|
2333
2377
|
handler: createAuthMiddleware(async (ctx) => {
|
|
2334
|
-
const
|
|
2335
|
-
const
|
|
2378
|
+
const untrustedVisitorId = ctx.context.untrustedVisitorId;
|
|
2379
|
+
const ip = ctx.context.ip;
|
|
2336
2380
|
const body = ctx.body;
|
|
2381
|
+
const loginId = matchesAnyRoute(ctx.path, [routes.SIGN_IN_USERNAME]) ? body?.username : body?.email;
|
|
2382
|
+
const isPasswordSignInRoute = matchesAnyRoute(ctx.path, [
|
|
2383
|
+
routes.SIGN_IN_EMAIL,
|
|
2384
|
+
routes.SIGN_IN_USERNAME,
|
|
2385
|
+
routes.SIGN_IN_EMAIL_OTP
|
|
2386
|
+
]);
|
|
2337
2387
|
emitEvaluation(ctx, trackEvent, {
|
|
2338
|
-
identifier:
|
|
2388
|
+
identifier: loginId,
|
|
2339
2389
|
userAgent: ctx.headers?.get?.("user-agent") || ""
|
|
2340
2390
|
});
|
|
2341
|
-
if (
|
|
2342
|
-
|
|
2343
|
-
const ip = identification?.ip || null;
|
|
2344
|
-
await ctx.context.runInBackgroundOrAwait(securityService.trackFailedAttempt(email, visitorId, password, ip));
|
|
2345
|
-
}
|
|
2346
|
-
if (matchesAnyRoute(ctx.path, [routes.SIGN_IN_EMAIL, routes.SIGN_IN_EMAIL_OTP]) && !(ctx.context.returned instanceof Error) && body?.email) {
|
|
2347
|
-
const email = body.email;
|
|
2348
|
-
await ctx.context.runInBackgroundOrAwait(securityService.clearFailedAttempts(email));
|
|
2349
|
-
}
|
|
2391
|
+
if (isPasswordSignInRoute && ctx.context.returned instanceof Error && loginId && body?.password && untrustedVisitorId) await ctx.context.runInBackgroundOrAwait(securityService.trackFailedAttempt(loginId, untrustedVisitorId, body.password, ip));
|
|
2392
|
+
if (isPasswordSignInRoute && !(ctx.context.returned instanceof Error) && loginId) await ctx.context.runInBackgroundOrAwait(securityService.clearFailedAttempts(loginId));
|
|
2350
2393
|
})
|
|
2351
2394
|
}]
|
|
2352
2395
|
}
|
|
@@ -2794,7 +2837,7 @@ const jwtValidateMiddleware = (options) => {
|
|
|
2794
2837
|
};
|
|
2795
2838
|
//#endregion
|
|
2796
2839
|
//#region src/version.ts
|
|
2797
|
-
const PLUGIN_VERSION = "0.2.
|
|
2840
|
+
const PLUGIN_VERSION = "0.2.12";
|
|
2798
2841
|
//#endregion
|
|
2799
2842
|
//#region src/routes/auth/config.ts
|
|
2800
2843
|
const PLUGIN_OPTIONS_EXCLUDE_KEYS = { stripe: new Set(["stripeClient"]) };
|
|
@@ -3636,6 +3679,31 @@ const executeAdapter = (options) => {
|
|
|
3636
3679
|
};
|
|
3637
3680
|
//#endregion
|
|
3638
3681
|
//#region src/routes/invitations/index.ts
|
|
3682
|
+
async function verifyPendingInvitation(token, $api, ctx) {
|
|
3683
|
+
const { data: invitation, error } = await $api("/api/internal/invitations/verify", {
|
|
3684
|
+
method: "POST",
|
|
3685
|
+
body: { token }
|
|
3686
|
+
});
|
|
3687
|
+
if (error || !invitation) {
|
|
3688
|
+
ctx.context.logger.warn("[Dash] Failed to verify invitation", error);
|
|
3689
|
+
throw new APIError("BAD_REQUEST", { message: "Invalid or expired invitation." });
|
|
3690
|
+
}
|
|
3691
|
+
if (invitation.status !== "pending") throw new APIError("BAD_REQUEST", { message: `This invitation has already been ${invitation.status}.` });
|
|
3692
|
+
if (invitation.expiresAt) {
|
|
3693
|
+
if (new Date(invitation.expiresAt) < /* @__PURE__ */ new Date()) {
|
|
3694
|
+
const { error: markError } = await $api("/api/internal/invitations/mark-expired", {
|
|
3695
|
+
method: "POST",
|
|
3696
|
+
body: { token }
|
|
3697
|
+
});
|
|
3698
|
+
if (markError) ctx.context.logger.warn("[Dash] Failed to mark invitation as expired", markError);
|
|
3699
|
+
throw new APIError("BAD_REQUEST", { message: "This invitation has expired." });
|
|
3700
|
+
}
|
|
3701
|
+
}
|
|
3702
|
+
return invitation;
|
|
3703
|
+
}
|
|
3704
|
+
function getFallbackRedirect(ctx) {
|
|
3705
|
+
return typeof ctx.context.options.baseURL === "string" ? ctx.context.options.baseURL : "/";
|
|
3706
|
+
}
|
|
3639
3707
|
/**
|
|
3640
3708
|
* Accept invitation endpoint
|
|
3641
3709
|
* This is called when a user clicks the invitation link.
|
|
@@ -3648,25 +3716,7 @@ const acceptInvitation = (options) => {
|
|
|
3648
3716
|
query: z$1.object({ token: z$1.string() })
|
|
3649
3717
|
}, async (ctx) => {
|
|
3650
3718
|
const { token } = ctx.query;
|
|
3651
|
-
const
|
|
3652
|
-
method: "POST",
|
|
3653
|
-
body: { token }
|
|
3654
|
-
});
|
|
3655
|
-
if (error || !invitation) {
|
|
3656
|
-
ctx.context.logger.warn("[Dash] Failed to verify invitation", error);
|
|
3657
|
-
throw new APIError("BAD_REQUEST", { message: "Invalid or expired invitation." });
|
|
3658
|
-
}
|
|
3659
|
-
if (invitation.status !== "pending") throw new APIError("BAD_REQUEST", { message: `This invitation has already been ${invitation.status}.` });
|
|
3660
|
-
if (invitation.expiresAt) {
|
|
3661
|
-
if (new Date(invitation.expiresAt) < /* @__PURE__ */ new Date()) {
|
|
3662
|
-
const { error: markError } = await $api("/api/internal/invitations/mark-expired", {
|
|
3663
|
-
method: "POST",
|
|
3664
|
-
body: { token }
|
|
3665
|
-
});
|
|
3666
|
-
if (markError) ctx.context.logger.warn("[Dash] Failed to mark invitation as expired", markError);
|
|
3667
|
-
throw new APIError("BAD_REQUEST", { message: "This invitation has expired." });
|
|
3668
|
-
}
|
|
3669
|
-
}
|
|
3719
|
+
const invitation = await verifyPendingInvitation(token, $api, ctx);
|
|
3670
3720
|
const invitationEmail = normalizeEmail(invitation.email, ctx.context);
|
|
3671
3721
|
const existingUser = await ctx.context.internalAdapter.findUserByEmail(invitationEmail).then((user) => user?.user);
|
|
3672
3722
|
if (existingUser) {
|
|
@@ -3727,24 +3777,12 @@ const completeInvitation = (options) => {
|
|
|
3727
3777
|
method: "POST",
|
|
3728
3778
|
body: z$1.object({
|
|
3729
3779
|
token: z$1.string(),
|
|
3730
|
-
password: z$1.string().optional()
|
|
3731
|
-
providerId: z$1.string().optional(),
|
|
3732
|
-
providerAccountId: z$1.string().optional(),
|
|
3733
|
-
accessToken: z$1.string().optional(),
|
|
3734
|
-
refreshToken: z$1.string().optional()
|
|
3780
|
+
password: z$1.string().optional()
|
|
3735
3781
|
})
|
|
3736
3782
|
}, async (ctx) => {
|
|
3737
|
-
const { token, password
|
|
3738
|
-
const fallbackRedirect =
|
|
3739
|
-
const
|
|
3740
|
-
method: "POST",
|
|
3741
|
-
body: { token }
|
|
3742
|
-
});
|
|
3743
|
-
if (error || !invitation) {
|
|
3744
|
-
ctx.context.logger.warn("[Dash] Failed to verify invitation", error);
|
|
3745
|
-
throw new APIError("BAD_REQUEST", { message: "Invalid or expired invitation." });
|
|
3746
|
-
}
|
|
3747
|
-
if (invitation.status !== "pending") throw new APIError("BAD_REQUEST", { message: `This invitation has already been ${invitation.status}.` });
|
|
3783
|
+
const { token, password } = ctx.body;
|
|
3784
|
+
const fallbackRedirect = getFallbackRedirect(ctx);
|
|
3785
|
+
const invitation = await verifyPendingInvitation(token, $api, ctx);
|
|
3748
3786
|
if (!ctx.context) throw new APIError("BAD_REQUEST", { message: "Context is required" });
|
|
3749
3787
|
const invitationEmail = normalizeEmail(invitation.email, ctx.context);
|
|
3750
3788
|
const existingUser = await ctx.context.internalAdapter.findUserByEmail(invitationEmail).then((user) => user?.user);
|
|
@@ -3766,6 +3804,7 @@ const completeInvitation = (options) => {
|
|
|
3766
3804
|
redirectUrl: invitation.redirectUrl ?? fallbackRedirect
|
|
3767
3805
|
};
|
|
3768
3806
|
}
|
|
3807
|
+
if (!password) throw new APIError("BAD_REQUEST", { message: "Password is required to complete this invitation." });
|
|
3769
3808
|
const user = await ctx.context.internalAdapter.createUser({
|
|
3770
3809
|
email: invitationEmail,
|
|
3771
3810
|
name: invitation.name || invitation.email.split("@")[0] || "",
|
|
@@ -3773,19 +3812,12 @@ const completeInvitation = (options) => {
|
|
|
3773
3812
|
createdAt: /* @__PURE__ */ new Date(),
|
|
3774
3813
|
updatedAt: /* @__PURE__ */ new Date()
|
|
3775
3814
|
});
|
|
3776
|
-
|
|
3815
|
+
await ctx.context.internalAdapter.createAccount({
|
|
3777
3816
|
userId: user.id,
|
|
3778
3817
|
providerId: "credential",
|
|
3779
3818
|
accountId: user.id,
|
|
3780
3819
|
password: await ctx.context.password.hash(password)
|
|
3781
3820
|
});
|
|
3782
|
-
else if (providerId && providerAccountId) await ctx.context.internalAdapter.createAccount({
|
|
3783
|
-
userId: user.id,
|
|
3784
|
-
providerId,
|
|
3785
|
-
accountId: providerAccountId,
|
|
3786
|
-
accessToken,
|
|
3787
|
-
refreshToken
|
|
3788
|
-
});
|
|
3789
3821
|
const { error: markError } = await $api("/api/internal/invitations/mark-accepted", {
|
|
3790
3822
|
method: "POST",
|
|
3791
3823
|
body: {
|
|
@@ -3804,6 +3836,30 @@ const completeInvitation = (options) => {
|
|
|
3804
3836
|
};
|
|
3805
3837
|
});
|
|
3806
3838
|
};
|
|
3839
|
+
const completeInvitationSocial = (options) => {
|
|
3840
|
+
const { $api } = options;
|
|
3841
|
+
return createAuthEndpoint("/dash/complete-invitation-social", {
|
|
3842
|
+
method: "GET",
|
|
3843
|
+
query: z$1.object({ token: z$1.string() }),
|
|
3844
|
+
use: [sessionMiddleware]
|
|
3845
|
+
}, async (ctx) => {
|
|
3846
|
+
const sessionUser = ctx.context.session?.user;
|
|
3847
|
+
if (!sessionUser) throw new APIError("UNAUTHORIZED", { message: "Authentication required." });
|
|
3848
|
+
const invitation = await verifyPendingInvitation(ctx.query.token, $api, ctx);
|
|
3849
|
+
const invitationEmail = normalizeEmail(invitation.email, ctx.context);
|
|
3850
|
+
const sessionEmail = sessionUser.email ? normalizeEmail(sessionUser.email, ctx.context) : null;
|
|
3851
|
+
if (!sessionEmail || sessionEmail !== invitationEmail) throw new APIError("BAD_REQUEST", { message: "Signed-in account does not match this invitation." });
|
|
3852
|
+
const { error: markError } = await $api("/api/internal/invitations/mark-accepted", {
|
|
3853
|
+
method: "POST",
|
|
3854
|
+
body: {
|
|
3855
|
+
token: ctx.query.token,
|
|
3856
|
+
userId: sessionUser.id
|
|
3857
|
+
}
|
|
3858
|
+
});
|
|
3859
|
+
if (markError) ctx.context.logger.warn("[Dash] Failed to mark invitation as accepted", markError);
|
|
3860
|
+
return ctx.redirect((invitation.redirectUrl || getFallbackRedirect(ctx)).toString());
|
|
3861
|
+
});
|
|
3862
|
+
};
|
|
3807
3863
|
/**
|
|
3808
3864
|
* Check if a user exists by email
|
|
3809
3865
|
* Used by the platform to verify before sending invitation
|
|
@@ -5694,8 +5750,14 @@ function isPrivateIPv4(a, b) {
|
|
|
5694
5750
|
if (a === 169 && b === 254) return true;
|
|
5695
5751
|
if (a === 0) return true;
|
|
5696
5752
|
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
5753
|
+
if (a >= 224) return true;
|
|
5697
5754
|
return false;
|
|
5698
5755
|
}
|
|
5756
|
+
function parseIPv4Quad(quad) {
|
|
5757
|
+
const v4parts = quad.split(".").map(Number);
|
|
5758
|
+
if (v4parts.length === 4 && v4parts.every((p) => !isNaN(p) && p >= 0 && p <= 255)) return [v4parts[0], v4parts[1]];
|
|
5759
|
+
return null;
|
|
5760
|
+
}
|
|
5699
5761
|
/**
|
|
5700
5762
|
* Loopback, RFC1918, link-local, CGNAT, ULA, documentation, NAT64 well-known,
|
|
5701
5763
|
* IPv4-mapped private, unspecified, and multicast (RFC 4291 section 2.7).
|
|
@@ -5732,12 +5794,23 @@ function parseIPv6(addr) {
|
|
|
5732
5794
|
if (left.length !== 8) return null;
|
|
5733
5795
|
return left;
|
|
5734
5796
|
}
|
|
5797
|
+
/** True when the hostname is an IPv4 or IPv6 literal (not a DNS name). */
|
|
5798
|
+
function isIpLiteralHostname(hostname) {
|
|
5799
|
+
const bare = hostname.replace(/^\[|\]$/g, "");
|
|
5800
|
+
if (parseIPv4Quad(bare)) return true;
|
|
5801
|
+
return parseIPv6(bare)?.length === 8;
|
|
5802
|
+
}
|
|
5735
5803
|
/** Returns true if the hostname resolves to a private/reserved IP or is a local hostname. */
|
|
5736
5804
|
function isPrivateHost(hostname) {
|
|
5737
|
-
if (hostname === "localhost" || hostname.endsWith(".local") || hostname.endsWith(".internal")) return true;
|
|
5805
|
+
if (hostname === "localhost" || hostname.endsWith(".local") || hostname.endsWith(".internal") || hostname.endsWith(".localhost")) return true;
|
|
5738
5806
|
const bare = hostname.replace(/^\[|\]$/g, "");
|
|
5739
|
-
const
|
|
5740
|
-
if (
|
|
5807
|
+
const v4 = parseIPv4Quad(bare);
|
|
5808
|
+
if (v4) return isPrivateIPv4(v4[0], v4[1]);
|
|
5809
|
+
const mappedTail = bare.match(/(?:^|:)([\d.]+)$/);
|
|
5810
|
+
if (mappedTail && mappedTail[1].includes(".")) {
|
|
5811
|
+
const mappedV4 = parseIPv4Quad(mappedTail[1]);
|
|
5812
|
+
if (mappedV4) return isPrivateIPv4(mappedV4[0], mappedV4[1]);
|
|
5813
|
+
}
|
|
5741
5814
|
const groups = parseIPv6(bare);
|
|
5742
5815
|
if (groups && groups.length === 8) return ipv6GroupsAreNonPublic(groups);
|
|
5743
5816
|
return false;
|
|
@@ -5822,13 +5895,20 @@ async function validateUrl(rawUrl, options = {}) {
|
|
|
5822
5895
|
const sync = validateUrlSync(rawUrl, options);
|
|
5823
5896
|
if (!sync.ok) return sync;
|
|
5824
5897
|
if (options.dns === false) return sync;
|
|
5825
|
-
const
|
|
5898
|
+
const hostname = sync.url.hostname;
|
|
5899
|
+
const addresses = await resolveHostnameAddresses(hostname);
|
|
5900
|
+
if (!isIpLiteralHostname(hostname) && !addresses?.length) return {
|
|
5901
|
+
ok: false,
|
|
5902
|
+
reason: "unresolved_host",
|
|
5903
|
+
url: sync.url.href,
|
|
5904
|
+
hostname
|
|
5905
|
+
};
|
|
5826
5906
|
if (addresses) {
|
|
5827
5907
|
for (const addr of addresses) if (isPrivateHost(addr)) return {
|
|
5828
5908
|
ok: false,
|
|
5829
5909
|
reason: "private_dns",
|
|
5830
5910
|
url: sync.url.href,
|
|
5831
|
-
hostname
|
|
5911
|
+
hostname,
|
|
5832
5912
|
address: addr
|
|
5833
5913
|
};
|
|
5834
5914
|
}
|
|
@@ -6571,41 +6651,13 @@ const viewTwoFactorTotpUri = (options) => createAuthEndpoint("/dash/view-two-fac
|
|
|
6571
6651
|
} catch {}
|
|
6572
6652
|
const user = await ctx.context.internalAdapter.findUserById(userId);
|
|
6573
6653
|
const issuer = twoFactorPlugin.options?.issuer || ctx.context.appName || "BetterAuth";
|
|
6574
|
-
return {
|
|
6575
|
-
totpURI: `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(user?.email || userId)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`,
|
|
6576
|
-
secret
|
|
6577
|
-
};
|
|
6654
|
+
return { totpURI: `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(user?.email || userId)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30` };
|
|
6578
6655
|
});
|
|
6579
6656
|
const viewBackupCodes = (options) => createAuthEndpoint("/dash/view-backup-codes", {
|
|
6580
6657
|
method: "POST",
|
|
6581
6658
|
use: [jwtMiddleware(options, z$1.object({ userId: z$1.string() }))]
|
|
6582
|
-
}, async (
|
|
6583
|
-
|
|
6584
|
-
const twoFactorRecord = await ctx.context.adapter.findOne({
|
|
6585
|
-
model: "twoFactor",
|
|
6586
|
-
where: [{
|
|
6587
|
-
field: "userId",
|
|
6588
|
-
value: userId
|
|
6589
|
-
}]
|
|
6590
|
-
});
|
|
6591
|
-
if (!twoFactorRecord) throw new APIError("NOT_FOUND", { message: "Two-factor authentication not set up for this user" });
|
|
6592
|
-
let backupCodes;
|
|
6593
|
-
try {
|
|
6594
|
-
backupCodes = JSON.parse(twoFactorRecord.backupCodes);
|
|
6595
|
-
} catch {
|
|
6596
|
-
try {
|
|
6597
|
-
const { symmetricDecrypt } = await import("better-auth/crypto");
|
|
6598
|
-
const decrypted = await symmetricDecrypt({
|
|
6599
|
-
key: ctx.context.secret,
|
|
6600
|
-
data: twoFactorRecord.backupCodes
|
|
6601
|
-
});
|
|
6602
|
-
backupCodes = JSON.parse(decrypted);
|
|
6603
|
-
} catch (decryptError) {
|
|
6604
|
-
ctx.context.logger.debug("[Dash] Failed to decrypt backup codes:", decryptError);
|
|
6605
|
-
throw new APIError("INTERNAL_SERVER_ERROR", { message: "Failed to decrypt backup codes" });
|
|
6606
|
-
}
|
|
6607
|
-
}
|
|
6608
|
-
return { backupCodes };
|
|
6659
|
+
}, async () => {
|
|
6660
|
+
throw new APIError("FORBIDDEN", { message: "Backup codes cannot be viewed after initial setup. Generate new codes instead." });
|
|
6609
6661
|
});
|
|
6610
6662
|
const disableTwoFactor = (options) => createAuthEndpoint("/dash/disable-two-factor", {
|
|
6611
6663
|
method: "POST",
|
|
@@ -8341,6 +8393,7 @@ const dash = (options) => {
|
|
|
8341
8393
|
getEventTypes: getEventTypes(settings),
|
|
8342
8394
|
dashAcceptInvitation: acceptInvitation(settings),
|
|
8343
8395
|
dashCompleteInvitation: completeInvitation(settings),
|
|
8396
|
+
dashCompleteInvitationSocial: completeInvitationSocial(settings),
|
|
8344
8397
|
dashCheckUserExists: checkUserExists(settings),
|
|
8345
8398
|
listDashOrganizationDirectories: listOrganizationDirectories(settings),
|
|
8346
8399
|
createDashOrganizationDirectory: createOrganizationDirectory(settings),
|
package/dist/native.mjs
CHANGED
|
@@ -190,6 +190,8 @@ async function getOrCreateVisitorId(storage) {
|
|
|
190
190
|
}
|
|
191
191
|
//#endregion
|
|
192
192
|
//#region src/sentinel/native/client.ts
|
|
193
|
+
/** One recovery round after a consumed or superseded challenge (e.g. double-submit). */
|
|
194
|
+
const MAX_POW_CHALLENGE_ROUNDS = 2;
|
|
193
195
|
function scheduleIdentify(send) {
|
|
194
196
|
const run = () => {
|
|
195
197
|
try {
|
|
@@ -248,49 +250,54 @@ const sentinelNativeClient = (options) => {
|
|
|
248
250
|
hooks: {
|
|
249
251
|
async onResponse(context) {
|
|
250
252
|
if (context.response.status !== 423 || !autoSolve) return context;
|
|
251
|
-
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
const
|
|
261
|
-
options?.
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
const retryHeaders = new Headers();
|
|
265
|
-
retryHeaders.set("X-PoW-Solution", encodePoWSolution(solution));
|
|
266
|
-
if (fingerprint) {
|
|
267
|
-
retryHeaders.set("X-Visitor-Id", fingerprint.visitorId);
|
|
268
|
-
retryHeaders.set("X-Request-Id", fingerprint.requestId);
|
|
269
|
-
}
|
|
270
|
-
retryHeaders.set("Content-Type", "application/json");
|
|
271
|
-
let body;
|
|
272
|
-
if (context.request?.body) body = context.request._originalBody;
|
|
273
|
-
const req = context.request;
|
|
274
|
-
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;
|
|
275
266
|
try {
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
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;
|
|
288
295
|
}
|
|
289
|
-
} catch (err) {
|
|
290
|
-
console.error("[Sentinel native] Failed to solve PoW challenge:", err);
|
|
291
|
-
options?.onChallengeFailed?.(err instanceof Error ? err : new Error(String(err)));
|
|
292
|
-
return context;
|
|
293
296
|
}
|
|
297
|
+
return {
|
|
298
|
+
...context,
|
|
299
|
+
response
|
|
300
|
+
};
|
|
294
301
|
},
|
|
295
302
|
async onRequest(context) {
|
|
296
303
|
if (context.body) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@better-auth/infra",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.12",
|
|
4
4
|
"description": "Dashboard and analytics plugin for Better Auth",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.mjs",
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
"@better-fetch/fetch": "^1.1.21",
|
|
80
80
|
"better-call": "^1.3.2",
|
|
81
81
|
"jose": "^6.1.0",
|
|
82
|
-
"libphonenumber-js": "^1.13.
|
|
82
|
+
"libphonenumber-js": "^1.13.3"
|
|
83
83
|
},
|
|
84
84
|
"peerDependencies": {
|
|
85
85
|
"better-auth": ">=1.4.0",
|