@better-auth/infra 0.3.2 → 0.3.3

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/CHANGELOG.md CHANGED
@@ -5,6 +5,24 @@ All notable changes to `@better-auth/infra` are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.3.3] - 2026-06-26
9
+
10
+ ### Added
11
+
12
+ - **`dashCompleteInvitationHandoff`** — New `GET /dash/complete-invitation-handoff` endpoint completes invitations on the auth server after the platform validates credentials via a one-time handoff, so session cookies reach the invitee's browser.
13
+
14
+ ### Changed
15
+
16
+ - **Invitation accept workflows** — Password and social completion endpoints enforce auth mode rules: `direct_redirect` invitations cannot use platform completion; `create_no_session` omits session cookies and revokes sessions after social completion; `create_with_session` allows passwordless user creation.
17
+ - **SSO domain verification** — `mark-domain-verified` no longer accepts `verified: true`; domains can only be verified through DNS (`verify-domain`) or have verification cleared.
18
+ - **User list pagination** — `getUsers` clamps `limit` (max 100) and `offset` to prevent expensive unbounded queries.
19
+ - **Email validation** — Validation always uses the configured policy strictness; the optional MX bypass has been removed.
20
+
21
+ ### Security
22
+
23
+ - **Trusted redirect URLs** — Invitation redirects, impersonation targets, and email callback URLs are resolved against the auth base URL and `trustedOrigins`; untrusted origins fall back or are rejected.
24
+ - **SCIM directory sync** — Creating a directory provider with an existing `providerId` in the same organization is rejected.
25
+
8
26
  ## [0.3.2] - 2026-06-22
9
27
 
10
28
  ### Fixed
@@ -91,6 +109,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
91
109
 
92
110
  - Fixed impossible travel security challenges.
93
111
 
112
+ [0.3.3]: https://github.com/better-auth/infrastructure/compare/infra@v0.3.2...infra@v0.3.3
113
+ [0.3.2]: https://github.com/better-auth/infrastructure/compare/infra@v0.3.1...infra@v0.3.2
94
114
  [0.3.1]: https://github.com/better-auth/infrastructure/compare/infra@v0.3.0...infra@v0.3.1
95
115
  [0.3.0]: https://github.com/better-auth/infrastructure/compare/infra@v0.2.14...infra@v0.3.0
96
116
  [0.2.14]: https://github.com/better-auth/better-auth-infra/compare/@better-auth/infra@0.2.13...@better-auth/infra@0.2.14
package/dist/client.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { n as hash, o as createKV } from "./crypto-B1NYwfFV.mjs";
2
- import { a as resolveSentinelClientIdentifyUrl, c as dashClient, i as solvePoWChallenge, n as decodePoWChallenge, o as generateRequestId, r as encodePoWSolution, s as identify, t as createPowRetryTimeout } from "./pow-retry-BfzBUq0O.mjs";
1
+ import { n as hash, o as createKV } from "./crypto-yhK7ZLPn.mjs";
2
+ import { a as resolveSentinelClientIdentifyUrl, c as dashClient, i as solvePoWChallenge, n as decodePoWChallenge, o as generateRequestId, r as encodePoWSolution, s as identify, t as createPowRetryTimeout } from "./pow-retry-BeuEd3ic.mjs";
3
3
  import { env } from "@better-auth/core/env";
4
4
  //#region src/sentinel/fingerprint.ts
5
5
  function murmurhash3(str, seed = 0) {
@@ -1,7 +1,7 @@
1
1
  import { env } from "@better-auth/core/env";
2
2
  //#endregion
3
3
  //#region src/version.ts
4
- const PLUGIN_VERSION = "0.3.2";
4
+ const PLUGIN_VERSION = "0.3.3";
5
5
  //#endregion
6
6
  //#region src/constants.ts
7
7
  /**
@@ -1,4 +1,4 @@
1
- import { i as INFRA_USER_AGENT } from "./constants-9UwOSy9A.mjs";
1
+ import { i as INFRA_USER_AGENT } from "./constants-z_1dN2O_.mjs";
2
2
  import { createFetch } from "@better-fetch/fetch";
3
3
  //#region src/fetch.ts
4
4
  function createAPI(options, fetchOptions) {
package/dist/email.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { i as INFRA_USER_AGENT, n as INFRA_API_URL } from "./constants-9UwOSy9A.mjs";
1
+ import { i as INFRA_USER_AGENT, n as INFRA_API_URL } from "./constants-z_1dN2O_.mjs";
2
2
  import { logger } from "better-auth";
3
3
  import { env } from "@better-auth/core/env";
4
4
  import { createFetch } from "@better-fetch/fetch";
package/dist/index.d.mts CHANGED
@@ -6921,7 +6921,7 @@ declare const dash: <O extends DashOptions>(options?: O) => {
6921
6921
  }>)[];
6922
6922
  body: import("zod").ZodObject<{
6923
6923
  providerId: import("zod").ZodString;
6924
- verified: import("zod").ZodBoolean;
6924
+ verified: import("zod").ZodLiteral<false>;
6925
6925
  }, import("zod/v4/core").$strip>;
6926
6926
  }, DashSsoMarkDomainVerifiedResponse>;
6927
6927
  listDashTeamMembers: import("better-call").StrictEndpoint<"/dash/organization/:orgId/teams/:teamId/members", {
@@ -7574,6 +7574,25 @@ declare const dash: <O extends DashOptions>(options?: O) => {
7574
7574
  password: import("zod").ZodOptional<import("zod").ZodString>;
7575
7575
  }, import("zod/v4/core").$strip>;
7576
7576
  }, DashCompleteInvitationResponse>;
7577
+ dashCompleteInvitationHandoff: import("better-call").StrictEndpoint<"/dash/complete-invitation-handoff", {
7578
+ method: "GET";
7579
+ query: import("zod").ZodObject<{
7580
+ handoff: import("zod").ZodString;
7581
+ }, import("zod/v4/core").$strip>;
7582
+ }, {
7583
+ 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;
7584
+ body: ({
7585
+ message?: string;
7586
+ code?: string;
7587
+ cause?: unknown;
7588
+ } & Record<string, any>) | undefined;
7589
+ headers: HeadersInit;
7590
+ statusCode: number;
7591
+ name: string;
7592
+ message: string;
7593
+ stack?: string;
7594
+ cause?: unknown;
7595
+ }>;
7577
7596
  dashCompleteInvitationSocial: import("better-call").StrictEndpoint<"/dash/complete-invitation-social", {
7578
7597
  method: "GET";
7579
7598
  query: import("zod").ZodObject<{
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { i as INFRA_USER_AGENT, n as INFRA_API_URL, o as PLUGIN_VERSION, r as INFRA_KV_URL } from "./constants-9UwOSy9A.mjs";
2
- import { a as createAPI, o as createKV, r as hmacSha256Hex } from "./crypto-B1NYwfFV.mjs";
1
+ import { i as INFRA_USER_AGENT, n as INFRA_API_URL, o as PLUGIN_VERSION, r as INFRA_KV_URL } from "./constants-z_1dN2O_.mjs";
2
+ import { a as createAPI, o as createKV, r as hmacSha256Hex } from "./crypto-yhK7ZLPn.mjs";
3
3
  import { EMAIL_TEMPLATES, createEmailSender, sendBulkEmails, sendEmail } from "./email.mjs";
4
4
  import { getCurrentAuthContext } from "@better-auth/core/context";
5
5
  import { APIError, generateId, getAuthTables, logger } from "better-auth";
@@ -9,7 +9,7 @@ import { createFetch } from "@better-fetch/fetch";
9
9
  import { isValidPhoneNumber, parsePhoneNumberFromString } from "libphonenumber-js";
10
10
  import { createLocalJWKSet, jwtVerify } from "jose";
11
11
  import z$1, { z } from "zod";
12
- import { setSessionCookie } from "better-auth/cookies";
12
+ import { deleteSessionCookie, setSessionCookie } from "better-auth/cookies";
13
13
  import { generateRandomString, symmetricEncrypt } from "better-auth/crypto";
14
14
  import { createOTP } from "@better-auth/utils/otp";
15
15
  //#region src/options.ts
@@ -1700,7 +1700,7 @@ function createEmailValidator(options) {
1700
1700
  * Validate an email address
1701
1701
  * Returns validation result - caller should use getPolicy() to determine action
1702
1702
  */
1703
- async validate(email, checkMx = true) {
1703
+ async validate(email) {
1704
1704
  const trimmed = email.trim();
1705
1705
  const policy = await fetchPolicy();
1706
1706
  if (!policy?.enabled) return {
@@ -1714,7 +1714,6 @@ async validate(email, checkMx = true) {
1714
1714
  method: "POST",
1715
1715
  body: {
1716
1716
  email: trimmed,
1717
- checkMx,
1718
1717
  strictness: policy.strictness
1719
1718
  }
1720
1719
  });
@@ -3348,6 +3347,18 @@ async function getScimProviderOwner(ctx, organizationId, providerId) {
3348
3347
  select: ["userId"]
3349
3348
  }))?.userId ?? null;
3350
3349
  }
3350
+ async function scimProviderExists(ctx, organizationId, providerId) {
3351
+ return await ctx.context.adapter.findOne({
3352
+ model: "scimProvider",
3353
+ where: [{
3354
+ field: "organizationId",
3355
+ value: organizationId
3356
+ }, {
3357
+ field: "providerId",
3358
+ value: providerId
3359
+ }]
3360
+ }) != null;
3361
+ }
3351
3362
  function getScimEndpoint(baseUrl) {
3352
3363
  return `${baseUrl}/scim/v2`;
3353
3364
  }
@@ -3415,6 +3426,7 @@ const createOrganizationDirectory = (options) => {
3415
3426
  const scimPlugin = getSCIMPlugin(ctx);
3416
3427
  if (!scimPlugin?.endpoints.generateSCIMToken || !isScimDirectorySyncEnabled(scimPlugin, ctx.context.version)) throw ctx.error("BAD_REQUEST", { message: "SCIM plugin is not enabled or does not support this feature" });
3417
3428
  const { providerId, ownerUserId } = ctx.body;
3429
+ if (await scimProviderExists(ctx, organizationId, providerId)) throw ctx.error("BAD_REQUEST", { message: DIRECTORY_SYNC_DUPLICATE_MESSAGE });
3418
3430
  let scimToken;
3419
3431
  try {
3420
3432
  scimToken = (await scimPlugin.endpoints.generateSCIMToken({
@@ -3895,6 +3907,61 @@ function usesPlatformInviteAcceptFlow(authMode) {
3895
3907
  function shouldCreateSessionOnInviteComplete(authMode) {
3896
3908
  return authMode !== "create_no_session";
3897
3909
  }
3910
+ /** Password/social completion on /invite/accept (not direct_redirect). */
3911
+ function canCompleteViaPlatformAuth(authMode) {
3912
+ return usesPlatformInviteAcceptFlow(authMode);
3913
+ }
3914
+ /** Whether complete-invitation may create a new user without a password. */
3915
+ function allowsPasswordlessInviteComplete(authMode) {
3916
+ return authMode === "create_with_session";
3917
+ }
3918
+ //#endregion
3919
+ //#region ../utils/dist/url.mjs
3920
+ /**
3921
+ * URL helpers: normalization, SSRF-safe parsing/validation, and path/query segments.
3922
+ */
3923
+ const HTTP_PROTOCOLS = ["http:", "https:"];
3924
+ function isAllowedProtocol(protocol, allowedProtocols) {
3925
+ return (allowedProtocols?.length ? allowedProtocols : HTTP_PROTOCOLS).includes(protocol);
3926
+ }
3927
+ function toAllowedOriginSet(allowedOrigins) {
3928
+ const set = /* @__PURE__ */ new Set();
3929
+ for (const raw of allowedOrigins) try {
3930
+ const parsed = new URL(raw.trim());
3931
+ if (isAllowedProtocol(parsed.protocol, HTTP_PROTOCOLS)) set.add(parsed.origin);
3932
+ } catch {}
3933
+ return set;
3934
+ }
3935
+ function isAllowedOrigin(origin, allowedOrigins) {
3936
+ if (!allowedOrigins.length) return false;
3937
+ return toAllowedOriginSet(allowedOrigins).has(origin);
3938
+ }
3939
+ function safeResolveUrl(raw, baseURL) {
3940
+ const trim = raw.trim();
3941
+ if (!trim) return null;
3942
+ if (trim.startsWith("//") || trim.includes("\\")) return null;
3943
+ if (/[\u0000-\u001F\u007F]/.test(trim)) return null;
3944
+ try {
3945
+ if (trim.startsWith("/")) return new URL(trim, baseURL);
3946
+ return new URL(trim);
3947
+ } catch {
3948
+ return null;
3949
+ }
3950
+ }
3951
+ function getSafeUniqueOrigins(urls) {
3952
+ return [...toAllowedOriginSet(urls)];
3953
+ }
3954
+ /**
3955
+ * Returns an absolute URL only if its protocol and origin are allowed (after
3956
+ * resolving relative paths against `baseURL`). Otherwise returns `null`.
3957
+ */
3958
+ function safeUrlParse(input) {
3959
+ if (input.raw == null || input.raw.trim() === "") return null;
3960
+ const target = safeResolveUrl(input.raw, input.baseURL);
3961
+ if (!target || !isAllowedProtocol(target.protocol, input.allowedProtocols)) return null;
3962
+ if (!isAllowedOrigin(target.origin, input.allowedOrigins ?? [])) return null;
3963
+ return target.href;
3964
+ }
3898
3965
  //#endregion
3899
3966
  //#region src/lib/safe-url.ts
3900
3967
  function isSafeHttpUrl(value) {
@@ -3908,12 +3975,44 @@ function isSafeHttpUrl(value) {
3908
3975
  /** http(s) absolute URL without embedded credentials. */
3909
3976
  const safeUrlSchema = z$1.string().trim().min(1).pipe(z$1.url().refine(isSafeHttpUrl, { message: "URL must be a valid http(s) URL without credentials" })).transform((value) => new URL(value).href);
3910
3977
  const optionalSafeUrlSchema = z$1.union([safeUrlSchema, z$1.literal("")]).optional();
3911
- /** Validates http(s) redirect targets at use time; falls back when invalid. */
3912
- function resolveSafeRedirectUrl(raw, fallback) {
3913
- if (raw == null || raw.trim() === "") return fallback;
3914
- const parsed = safeUrlSchema.safeParse(raw);
3915
- if (!parsed.success) return fallback;
3916
- return parsed.data;
3978
+ function getAuthBaseUrl(ctx) {
3979
+ const baseURL = ctx.context.options.baseURL;
3980
+ return typeof baseURL === "string" && baseURL.trim() ? baseURL : "/";
3981
+ }
3982
+ function getAllowedAuthOrigins(ctx, extraOrigins = []) {
3983
+ const baseURL = getAuthBaseUrl(ctx);
3984
+ const trusted = ctx.context.options.trustedOrigins;
3985
+ return getSafeUniqueOrigins([
3986
+ baseURL,
3987
+ ...Array.isArray(trusted) ? trusted.filter((origin) => typeof origin === "string") : [],
3988
+ ...extraOrigins
3989
+ ]);
3990
+ }
3991
+ /**
3992
+ * Resolve a redirect against the auth server's base URL and trusted origins.
3993
+ * Falls back when the target origin is not allowed.
3994
+ */
3995
+ function resolveTrustedAuthRedirectUrl(ctx, raw, fallback) {
3996
+ return safeUrlParse({
3997
+ raw,
3998
+ baseURL: getAuthBaseUrl(ctx),
3999
+ allowedOrigins: getAllowedAuthOrigins(ctx)
4000
+ }) ?? fallback;
4001
+ }
4002
+ /** Returns a trusted redirect URL or null when the origin is not allowed. */
4003
+ function parseTrustedAuthRedirectUrl(ctx, raw) {
4004
+ return safeUrlParse({
4005
+ raw,
4006
+ baseURL: getAuthBaseUrl(ctx),
4007
+ allowedOrigins: getAllowedAuthOrigins(ctx)
4008
+ });
4009
+ }
4010
+ const TRUSTED_CALLBACK_URL_ERROR = "Callback URL must use http(s) and match the auth base URL or a trusted origin.";
4011
+ /** Rejects email/redirect callbacks whose origin is not on the auth allowlist. */
4012
+ function requireTrustedAuthCallbackUrl(ctx, raw) {
4013
+ const trusted = parseTrustedAuthRedirectUrl(ctx, raw);
4014
+ if (!trusted) throw new APIError$1("BAD_REQUEST", { message: TRUSTED_CALLBACK_URL_ERROR });
4015
+ return trusted;
3917
4016
  }
3918
4017
  //#endregion
3919
4018
  //#region src/routes/invitations/index.ts
@@ -3939,11 +4038,56 @@ async function verifyPendingInvitation(token, $api, ctx) {
3939
4038
  }
3940
4039
  return invitation;
3941
4040
  }
3942
- function getFallbackRedirect(ctx) {
3943
- return typeof ctx.context.options.baseURL === "string" ? ctx.context.options.baseURL : "/";
3944
- }
3945
- function resolveInvitationRedirect(ctx, raw) {
3946
- return resolveSafeRedirectUrl(raw, getFallbackRedirect(ctx));
4041
+ async function executePlatformInvitationCompletion(ctx, $api, invitation, args) {
4042
+ if (!canCompleteViaPlatformAuth(invitation.authMode)) throw new APIError("BAD_REQUEST", { message: "This invitation cannot be completed through this endpoint." });
4043
+ if (!ctx.context) throw new APIError("BAD_REQUEST", { message: "Context is required" });
4044
+ const { token, password } = args;
4045
+ const invitationEmail = normalizeEmail(invitation.email, ctx.context);
4046
+ const existingUser = await ctx.context.internalAdapter.findUserByEmail(invitationEmail).then((user) => user?.user);
4047
+ if (existingUser) {
4048
+ const { error: markError } = await $api("/api/internal/invitations/mark-accepted", {
4049
+ method: "POST",
4050
+ body: {
4051
+ token,
4052
+ userId: existingUser.id
4053
+ }
4054
+ });
4055
+ if (markError) ctx.context.logger.warn("[Dash] Failed to mark invitation as accepted", markError);
4056
+ if (shouldCreateSessionOnInviteComplete(invitation.authMode)) await setSessionCookie(ctx, {
4057
+ session: await ctx.context.internalAdapter.createSession(existingUser.id),
4058
+ user: existingUser
4059
+ });
4060
+ return { redirectUrl: resolveTrustedAuthRedirectUrl(ctx, invitation.redirectUrl, getAuthBaseUrl(ctx)) };
4061
+ }
4062
+ if (!password && !allowsPasswordlessInviteComplete(invitation.authMode)) throw new APIError("BAD_REQUEST", { message: "Password is required to complete this invitation." });
4063
+ const userPayload = {
4064
+ email: invitationEmail,
4065
+ name: invitation.name || invitation.email.split("@")[0] || "",
4066
+ emailVerified: true,
4067
+ createdAt: /* @__PURE__ */ new Date(),
4068
+ updatedAt: /* @__PURE__ */ new Date()
4069
+ };
4070
+ const adapter = ctx.context.internalAdapter;
4071
+ const user = isVersionAtLeast(ctx.context.version, "1.7.0") ? await adapter.createUser(userPayload, { method: "admin" }) : await adapter.createUser(userPayload);
4072
+ if (password) await ctx.context.internalAdapter.createAccount({
4073
+ userId: user.id,
4074
+ providerId: "credential",
4075
+ accountId: user.id,
4076
+ password: await ctx.context.password.hash(password)
4077
+ });
4078
+ const { error: markError } = await $api("/api/internal/invitations/mark-accepted", {
4079
+ method: "POST",
4080
+ body: {
4081
+ token,
4082
+ userId: user.id
4083
+ }
4084
+ });
4085
+ if (markError) ctx.context.logger.warn("[Dash] Failed to mark invitation as accepted", markError);
4086
+ if (shouldCreateSessionOnInviteComplete(invitation.authMode)) await setSessionCookie(ctx, {
4087
+ session: await ctx.context.internalAdapter.createSession(user.id),
4088
+ user
4089
+ });
4090
+ return { redirectUrl: resolveTrustedAuthRedirectUrl(ctx, invitation.redirectUrl, getAuthBaseUrl(ctx)) };
3947
4091
  }
3948
4092
  /**
3949
4093
  * Accept invitation endpoint
@@ -3973,7 +4117,7 @@ const acceptInvitation = (options) => {
3973
4117
  session: await ctx.context.internalAdapter.createSession(existingUser.id),
3974
4118
  user: existingUser
3975
4119
  });
3976
- const redirectUrl = resolveInvitationRedirect(ctx, invitation.redirectUrl);
4120
+ const redirectUrl = resolveTrustedAuthRedirectUrl(ctx, invitation.redirectUrl, getAuthBaseUrl(ctx));
3977
4121
  return ctx.redirect(redirectUrl);
3978
4122
  }
3979
4123
  if (usesPlatformInviteAcceptFlow(invitation.authMode)) {
@@ -4005,7 +4149,7 @@ const acceptInvitation = (options) => {
4005
4149
  session: await ctx.context.internalAdapter.createSession(user.id),
4006
4150
  user
4007
4151
  });
4008
- const redirectUrl = resolveInvitationRedirect(ctx, invitation.redirectUrl);
4152
+ const redirectUrl = resolveTrustedAuthRedirectUrl(ctx, invitation.redirectUrl, getAuthBaseUrl(ctx));
4009
4153
  return ctx.redirect(redirectUrl);
4010
4154
  });
4011
4155
  };
@@ -4024,62 +4168,38 @@ const completeInvitation = (options) => {
4024
4168
  })
4025
4169
  }, async (ctx) => {
4026
4170
  const { token, password } = ctx.body;
4027
- const invitation = await verifyPendingInvitation(token, $api, ctx);
4028
- if (!ctx.context) throw new APIError("BAD_REQUEST", { message: "Context is required" });
4029
- const invitationEmail = normalizeEmail(invitation.email, ctx.context);
4030
- const existingUser = await ctx.context.internalAdapter.findUserByEmail(invitationEmail).then((user) => user?.user);
4031
- if (existingUser) {
4032
- const { error: markError } = await $api("/api/internal/invitations/mark-accepted", {
4033
- method: "POST",
4034
- body: {
4035
- token,
4036
- userId: existingUser.id
4037
- }
4038
- });
4039
- if (markError) ctx.context.logger.warn("[Dash] Failed to mark invitation as accepted", markError);
4040
- if (shouldCreateSessionOnInviteComplete(invitation.authMode)) await setSessionCookie(ctx, {
4041
- session: await ctx.context.internalAdapter.createSession(existingUser.id),
4042
- user: existingUser
4043
- });
4044
- return {
4045
- success: true,
4046
- redirectUrl: resolveInvitationRedirect(ctx, invitation.redirectUrl)
4047
- };
4048
- }
4049
- if (!password) throw new APIError("BAD_REQUEST", { message: "Password is required to complete this invitation." });
4050
- const userPayload = {
4051
- email: invitationEmail,
4052
- name: invitation.name || invitation.email.split("@")[0] || "",
4053
- emailVerified: true,
4054
- createdAt: /* @__PURE__ */ new Date(),
4055
- updatedAt: /* @__PURE__ */ new Date()
4056
- };
4057
- const adapter = ctx.context.internalAdapter;
4058
- const user = isVersionAtLeast(ctx.context.version, "1.7.0") ? await adapter.createUser(userPayload, { method: "admin" }) : await adapter.createUser(userPayload);
4059
- await ctx.context.internalAdapter.createAccount({
4060
- userId: user.id,
4061
- providerId: "credential",
4062
- accountId: user.id,
4063
- password: await ctx.context.password.hash(password)
4064
- });
4065
- const { error: markError } = await $api("/api/internal/invitations/mark-accepted", {
4066
- method: "POST",
4067
- body: {
4068
- token,
4069
- userId: user.id
4070
- }
4071
- });
4072
- if (markError) ctx.context.logger.warn("[Dash] Failed to mark invitation as accepted", markError);
4073
- if (shouldCreateSessionOnInviteComplete(invitation.authMode)) await setSessionCookie(ctx, {
4074
- session: await ctx.context.internalAdapter.createSession(user.id),
4075
- user
4171
+ const { redirectUrl } = await executePlatformInvitationCompletion(ctx, $api, await verifyPendingInvitation(token, $api, ctx), {
4172
+ token,
4173
+ password
4076
4174
  });
4077
4175
  return {
4078
4176
  success: true,
4079
- redirectUrl: resolveInvitationRedirect(ctx, invitation.redirectUrl)
4177
+ redirectUrl
4080
4178
  };
4081
4179
  });
4082
4180
  };
4181
+ /**
4182
+ * Browser handoff completion — runs on the auth server so Set-Cookie reaches the invitee.
4183
+ * The platform stores a one-time handoff after validating the invitation (and password).
4184
+ */
4185
+ const completeInvitationHandoff = (options) => {
4186
+ const { $api } = options;
4187
+ return createAuthEndpoint("/dash/complete-invitation-handoff", {
4188
+ method: "GET",
4189
+ query: z$1.object({ handoff: z$1.string().min(1) })
4190
+ }, async (ctx) => {
4191
+ const { data, error } = await $api("/api/internal/invitations/redeem-handoff", {
4192
+ method: "POST",
4193
+ body: { handoff: ctx.query.handoff }
4194
+ });
4195
+ if (error || !data?.invitationToken) throw new APIError("BAD_REQUEST", { message: "This invitation link has expired. Please try again." });
4196
+ const { redirectUrl } = await executePlatformInvitationCompletion(ctx, $api, await verifyPendingInvitation(data.invitationToken, $api, ctx), {
4197
+ token: data.invitationToken,
4198
+ password: data.password ?? void 0
4199
+ });
4200
+ return ctx.redirect(redirectUrl);
4201
+ });
4202
+ };
4083
4203
  const completeInvitationSocial = (options) => {
4084
4204
  const { $api } = options;
4085
4205
  return createAuthEndpoint("/dash/complete-invitation-social", {
@@ -4090,6 +4210,7 @@ const completeInvitationSocial = (options) => {
4090
4210
  const sessionUser = ctx.context.session?.user;
4091
4211
  if (!sessionUser) throw new APIError("UNAUTHORIZED", { message: "Authentication required." });
4092
4212
  const invitation = await verifyPendingInvitation(ctx.query.token, $api, ctx);
4213
+ if (!canCompleteViaPlatformAuth(invitation.authMode)) throw new APIError("BAD_REQUEST", { message: "This invitation cannot be completed through this endpoint." });
4093
4214
  const invitationEmail = normalizeEmail(invitation.email, ctx.context);
4094
4215
  const sessionEmail = sessionUser.email ? normalizeEmail(sessionUser.email, ctx.context) : null;
4095
4216
  if (!sessionEmail || sessionEmail !== invitationEmail) throw new APIError("BAD_REQUEST", { message: "Signed-in account does not match this invitation." });
@@ -4101,7 +4222,12 @@ const completeInvitationSocial = (options) => {
4101
4222
  }
4102
4223
  });
4103
4224
  if (markError) ctx.context.logger.warn("[Dash] Failed to mark invitation as accepted", markError);
4104
- return ctx.redirect(resolveInvitationRedirect(ctx, invitation.redirectUrl));
4225
+ if (!shouldCreateSessionOnInviteComplete(invitation.authMode)) {
4226
+ const sessionCookieToken = await ctx.getSignedCookie(ctx.context.authCookies.sessionToken.name, ctx.context.secret);
4227
+ if (sessionCookieToken) await ctx.context.internalAdapter.deleteSession(sessionCookieToken);
4228
+ deleteSessionCookie(ctx);
4229
+ }
4230
+ return ctx.redirect(resolveTrustedAuthRedirectUrl(ctx, invitation.redirectUrl, getAuthBaseUrl(ctx)));
4105
4231
  });
4106
4232
  };
4107
4233
  /**
@@ -6532,14 +6658,14 @@ const markSsoProviderDomainVerified = (options) => {
6532
6658
  use: [jwtMiddleware(options, z$1.object({ organizationId: z$1.string() }))],
6533
6659
  body: z$1.object({
6534
6660
  providerId: z$1.string(),
6535
- verified: z$1.boolean()
6661
+ verified: z$1.literal(false).describe("Clears domain verification. Domains can only be marked verified via DNS (verify-domain).")
6536
6662
  })
6537
6663
  }, async (ctx) => {
6538
6664
  requireOrganizationPlugin(ctx);
6539
6665
  requireOrganizationAccess(ctx);
6540
6666
  if (!getSSOPlugin(ctx)?.options?.domainVerification?.enabled) throw ctx.error("BAD_REQUEST", { message: "SSO plugin with domain verification is not enabled or feature is not supported in your plugin version" });
6541
6667
  const organizationId = tryDecode(ctx.params.id);
6542
- const { providerId, verified } = ctx.body;
6668
+ const { providerId } = ctx.body;
6543
6669
  const provider = await ctx.context.adapter.findOne({
6544
6670
  model: "ssoProvider",
6545
6671
  where: [{
@@ -6557,12 +6683,12 @@ const markSsoProviderDomainVerified = (options) => {
6557
6683
  field: "providerId",
6558
6684
  value: provider.providerId
6559
6685
  }],
6560
- update: { domainVerified: verified }
6686
+ update: { domainVerified: false }
6561
6687
  });
6562
6688
  return {
6563
6689
  success: true,
6564
- domainVerified: verified,
6565
- message: verified ? "Domain marked as verified" : "Domain verification unmarked"
6690
+ domainVerified: false,
6691
+ message: "Domain verification unmarked"
6566
6692
  };
6567
6693
  });
6568
6694
  };
@@ -7004,6 +7130,18 @@ function parseWhereClause(val) {
7004
7130
  if (!Array.isArray(parsed)) return [];
7005
7131
  return parsed;
7006
7132
  }
7133
+ const USER_LIST_MAX_LIMIT = 100;
7134
+ const USER_LIST_DEFAULT_LIMIT = 10;
7135
+ function clampUserListLimit(value, fallback = USER_LIST_DEFAULT_LIMIT) {
7136
+ const n = typeof value === "number" ? value : Number(value);
7137
+ if (!Number.isFinite(n)) return fallback;
7138
+ return Math.min(Math.max(1, Math.floor(n)), USER_LIST_MAX_LIMIT);
7139
+ }
7140
+ function clampUserListOffset(value, fallback = 0) {
7141
+ const n = typeof value === "number" ? value : Number(value);
7142
+ if (!Number.isFinite(n)) return fallback;
7143
+ return Math.max(0, Math.floor(n));
7144
+ }
7007
7145
  const getUsersQuerySchema = z$1.object({
7008
7146
  limit: z$1.number().or(z$1.string().transform(Number)).optional(),
7009
7147
  offset: z$1.number().or(z$1.string().transform(Number)).optional(),
@@ -7027,10 +7165,12 @@ const getUsers = (options) => {
7027
7165
  })();
7028
7166
  const where = ctx.query?.where?.length ? ctx.query.where : void 0;
7029
7167
  const countWhere = ctx.query?.countWhere?.length ? ctx.query.countWhere : void 0;
7168
+ const limit = clampUserListLimit(ctx.query?.limit, USER_LIST_DEFAULT_LIMIT);
7169
+ const offset = clampUserListOffset(ctx.query?.offset);
7030
7170
  const userQuery = ctx.context.adapter.findMany({
7031
7171
  model: "user",
7032
- limit: ctx.query?.limit || 10,
7033
- offset: ctx.query?.offset ? ctx.query.offset : 0,
7172
+ limit,
7173
+ offset,
7034
7174
  sortBy: {
7035
7175
  field: ctx.query?.sortBy || "createdAt",
7036
7176
  direction: ctx.query?.sortOrder || "desc"
@@ -7168,6 +7308,8 @@ const impersonateUser = (options) => {
7168
7308
  }, async (ctx) => {
7169
7309
  const { userId, redirectUrl, impersonatedBy } = ctx.context.payload;
7170
7310
  if (!(userId && redirectUrl)) throw ctx.error("BAD_REQUEST", { message: "Invalid token" });
7311
+ const trustedRedirectUrl = parseTrustedAuthRedirectUrl(ctx, redirectUrl);
7312
+ if (!trustedRedirectUrl) throw ctx.error("BAD_REQUEST", { message: "Invalid redirect URL" });
7171
7313
  const session = await ctx.context.internalAdapter.createSession(userId, true, {
7172
7314
  expiresAt: new Date(Date.now() + 1e3 * 60 * 10),
7173
7315
  impersonatedBy: impersonatedBy || void 0
@@ -7178,7 +7320,7 @@ const impersonateUser = (options) => {
7178
7320
  session,
7179
7321
  user
7180
7322
  }, true);
7181
- throw ctx.redirect(redirectUrl);
7323
+ throw ctx.redirect(trustedRedirectUrl);
7182
7324
  });
7183
7325
  };
7184
7326
  const createUser = (options) => {
@@ -8019,7 +8161,7 @@ const sendVerificationEmail = (options) => createAuthEndpoint("/dash/send-verifi
8019
8161
  body: z$1.object({ callbackUrl: safeUrlSchema })
8020
8162
  }, async (ctx) => {
8021
8163
  const { userId } = ctx.context.payload;
8022
- const { callbackUrl } = ctx.body;
8164
+ const callbackUrl = requireTrustedAuthCallbackUrl(ctx, ctx.body.callbackUrl);
8023
8165
  const user = await ctx.context.internalAdapter.findUserById(userId);
8024
8166
  if (!user) throw ctx.error("NOT_FOUND", { message: "User not found" });
8025
8167
  if (user.emailVerified) throw ctx.error("BAD_REQUEST", { message: "Email is already verified" });
@@ -8042,7 +8184,7 @@ const sendManyVerificationEmails = (options) => {
8042
8184
  }, async (ctx) => {
8043
8185
  if (!ctx.context.options.emailVerification?.sendVerificationEmail) throw ctx.error("BAD_REQUEST", { message: "Email verification is not enabled" });
8044
8186
  const { userIds } = ctx.context.payload;
8045
- const { callbackUrl } = ctx.body;
8187
+ const callbackUrl = requireTrustedAuthCallbackUrl(ctx, ctx.body.callbackUrl);
8046
8188
  const sentEmailUserIds = /* @__PURE__ */ new Set();
8047
8189
  const skippedEmailUserIds = /* @__PURE__ */ new Set();
8048
8190
  const start = performance.now();
@@ -8101,10 +8243,11 @@ const sendResetPasswordEmail = (options) => createAuthEndpoint("/dash/send-reset
8101
8243
  body: z$1.object({ callbackUrl: safeUrlSchema })
8102
8244
  }, async (ctx) => {
8103
8245
  const { userId } = ctx.context.payload;
8246
+ const callbackUrl = requireTrustedAuthCallbackUrl(ctx, ctx.body.callbackUrl);
8104
8247
  const user = await ctx.context.internalAdapter.findUserById(userId);
8105
8248
  if (!user) throw ctx.error("NOT_FOUND", { message: "User not found" });
8106
8249
  if (!user.email) throw ctx.error("BAD_REQUEST", { message: "User has no associated email address" });
8107
- ctx.body.redirectTo = ctx.body.callbackUrl;
8250
+ ctx.body.redirectTo = callbackUrl;
8108
8251
  ctx.body.email = user.email;
8109
8252
  return await requestPasswordReset(ctx);
8110
8253
  });
@@ -8717,6 +8860,7 @@ const dash = (options) => {
8717
8860
  getEventTypes: getEventTypes(settings),
8718
8861
  dashAcceptInvitation: acceptInvitation(settings),
8719
8862
  dashCompleteInvitation: completeInvitation(settings),
8863
+ dashCompleteInvitationHandoff: completeInvitationHandoff(settings),
8720
8864
  dashCompleteInvitationSocial: completeInvitationSocial(settings),
8721
8865
  dashCheckUserExists: checkUserExists(settings),
8722
8866
  listDashOrganizationDirectories: listOrganizationDirectories(settings),
package/dist/native.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { o as createKV, t as bytesToHex } from "./crypto-B1NYwfFV.mjs";
2
- import { a as resolveSentinelClientIdentifyUrl, c as dashClient, i as solvePoWChallenge, n as decodePoWChallenge, r as encodePoWSolution, s as identify, t as createPowRetryTimeout } from "./pow-retry-BfzBUq0O.mjs";
1
+ import { o as createKV, t as bytesToHex } from "./crypto-yhK7ZLPn.mjs";
2
+ import { a as resolveSentinelClientIdentifyUrl, c as dashClient, i as solvePoWChallenge, n as decodePoWChallenge, r as encodePoWSolution, s as identify, t as createPowRetryTimeout } from "./pow-retry-BeuEd3ic.mjs";
3
3
  import { env } from "@better-auth/core/env";
4
4
  import { Dimensions, InteractionManager, PixelRatio, Platform } from "react-native";
5
5
  //#region src/sentinel/native/components.ts
@@ -1,4 +1,4 @@
1
- import { i as randomBytes, n as hash, t as bytesToHex } from "./crypto-B1NYwfFV.mjs";
1
+ import { i as randomBytes, n as hash, t as bytesToHex } from "./crypto-yhK7ZLPn.mjs";
2
2
  //#region src/dash-client.ts
3
3
  function resolveDashUserId(input, options) {
4
4
  return input.userId || options?.resolveUserId?.({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@better-auth/infra",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "Dashboard and analytics plugin for Better Auth",
5
5
  "type": "module",
6
6
  "main": "dist/index.mjs",