@better-auth/infra 0.4.1 → 0.4.2

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,17 @@ 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.4.2] - 2026-08-21
9
+
10
+ ### Fixed
11
+
12
+ - **Identification on OAuth callbacks** — Sentinel and dash run identification on GET OAuth callback routes and persist `X-Request-Id` as the `__infra-rid` cookie in an after hook, so visitor identity resolves from KV across redirect-based social/OAuth sign-in (where identify headers are absent).
13
+ - **Issuer-scoped account lookups** — OAuth callback user resolution honors `account.identityStrategy` (`issuer` vs `provider-id`) when deriving account keys, matching Better Auth 1.7 issuer-scoped identity and the provider-scoped compatibility mode.
14
+
15
+ ### Changed
16
+
17
+ - **No runtime dependency on SCIM / SSO packages** — Directory-sync SCIM user-link acquisition and SAML SP policy checks are implemented in-package (aligned with `@better-auth/scim` / `@better-auth/sso`). Those packages remain type-only deps and are excluded from the published bundle.
18
+
8
19
  ## [0.4.1] - 2026-08-19
9
20
 
10
21
  ### Fixed
package/dist/client.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { n as hash, o as createKV } from "./crypto-CV91nSbp.mjs";
2
- import { a as resolveSentinelClientIdentifyUrl, c as identify, i as solvePoWChallenge, l as dashClient, n as decodePoWChallenge, o as resolveIdentifyClientRetry, r as encodePoWSolution, s as generateRequestId, t as createPowRetryTimeout } from "./pow-retry-7sPi32Ow.mjs";
1
+ import { n as hash, o as createKV } from "./crypto-D3yZH0Cy.mjs";
2
+ import { a as resolveSentinelClientIdentifyUrl, c as identify, i as solvePoWChallenge, l as dashClient, n as decodePoWChallenge, o as resolveIdentifyClientRetry, r as encodePoWSolution, s as generateRequestId, t as createPowRetryTimeout } from "./pow-retry-jWGI__oh.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.4.1";
4
+ const PLUGIN_VERSION = "0.4.2";
5
5
  //#endregion
6
6
  //#region src/constants.ts
7
7
  /**
@@ -1,4 +1,4 @@
1
- import { i as INFRA_USER_AGENT } from "./constants-CtvGRrlI.mjs";
1
+ import { i as INFRA_USER_AGENT } from "./constants-CDhjfr8S.mjs";
2
2
  import { createFetch } from "@better-fetch/fetch";
3
3
  import { sha256 } from "@noble/hashes/sha2.js";
4
4
  //#region src/fetch.ts
package/dist/email.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { i as INFRA_USER_AGENT, n as INFRA_API_URL } from "./constants-CtvGRrlI.mjs";
1
+ import { i as INFRA_USER_AGENT, n as INFRA_API_URL } from "./constants-CDhjfr8S.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
@@ -249,7 +249,22 @@ type DashSCIMManagedConnectionEventType = "connection.created" | "credential.iss
249
249
  /** Lifecycle state for one Infrastructure-owned directory-sync alias. */
250
250
  type DirectorySyncConnectionStatus = "active" | "decommissioning" | "decommissioned";
251
251
  type DirectorySyncMode = "legacy" | "managed" | "unavailable";
252
- type SCIMPlugin = ReturnType<typeof import("@better-auth/scim").scim>;
252
+ /**
253
+ * Structural stand-in for the SCIM plugin instance.
254
+ * Avoids `import("@better-auth/scim")` on the public type surface so consumers
255
+ * without that package can still typecheck the root entry.
256
+ */
257
+ type SCIMPlugin = {
258
+ id?: string;
259
+ options?: {
260
+ providerOwnership?: {
261
+ enabled?: boolean;
262
+ };
263
+ managedConnections?: unknown;
264
+ [key: string]: unknown;
265
+ };
266
+ endpoints: Record<string, unknown>;
267
+ };
253
268
  /** Public credential metadata. Raw bearer tokens are never included here. */
254
269
  interface DashDirectoryCredential {
255
270
  credentialId: string;
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-CtvGRrlI.mjs";
2
- import { a as createAPI, n as hash$1, o as createKV, r as hmacSha256Hex } from "./crypto-CV91nSbp.mjs";
1
+ import { i as INFRA_USER_AGENT, n as INFRA_API_URL, o as PLUGIN_VERSION, r as INFRA_KV_URL } from "./constants-CDhjfr8S.mjs";
2
+ import { a as createAPI, n as hash$1, o as createKV, r as hmacSha256Hex } from "./crypto-D3yZH0Cy.mjs";
3
3
  import { EMAIL_TEMPLATES, createEmailSender, sendBulkEmails, sendEmail } from "./email.mjs";
4
4
  import { getCurrentAdapter, getCurrentAuthContext, getCurrentDBAdapterAsyncLocalStorage, runWithTransaction } from "@better-auth/core/context";
5
5
  import { APIError, generateId, getAuthTables, logger } from "better-auth";
@@ -7,8 +7,11 @@ import { env } from "@better-auth/core/env";
7
7
  import { APIError as APIError$1, createAuthEndpoint, createAuthMiddleware, requestPasswordReset, sendVerificationEmailFn, sessionMiddleware } from "better-auth/api";
8
8
  import { deleteSessionCookie, setSessionCookie } from "better-auth/cookies";
9
9
  import { createFetch } from "@better-fetch/fetch";
10
+ import { sha256 } from "@noble/hashes/sha2.js";
10
11
  import { isValidPhoneNumber, parsePhoneNumberFromString } from "libphonenumber-js";
11
12
  import z, { z as z$1 } from "zod";
13
+ import { DOMParser } from "@xmldom/xmldom";
14
+ import { base64Url } from "@better-auth/utils/base64";
12
15
  import { createLocalJWKSet, jwtVerify } from "jose";
13
16
  import { generateRandomString, symmetricEncrypt } from "better-auth/crypto";
14
17
  import { createOTP } from "@better-auth/utils/otp";
@@ -374,6 +377,33 @@ function createLocalAccountIssuer(providerId) {
374
377
  function createOAuthAccountIssuer(providerId) {
375
378
  return `local:oauth:${encodeURIComponent(providerId)}`;
376
379
  }
380
+ /** Configured strategy when set; `null` when omitted (pre-option or default). */
381
+ function getConfiguredAccountIdentityStrategy(account) {
382
+ const strategy = account?.identityStrategy;
383
+ if (strategy === "provider-id" || strategy === "issuer") return strategy;
384
+ return null;
385
+ }
386
+ /**
387
+ * Effective strategy for account-key derivation.
388
+ * Matches better-auth: omit/`"issuer"` → issuer-scoped; `"provider-id"` → provider-scoped.
389
+ */
390
+ function resolveAccountIdentityStrategy(account) {
391
+ return getConfiguredAccountIdentityStrategy(account) ?? "issuer";
392
+ }
393
+ /**
394
+ * Resolves the OAuth account issuer the same way better-auth does under
395
+ * `account.identityStrategy`, including provider-scoped compatibility mode.
396
+ */
397
+ async function resolveOAuthAccountIssuer(params) {
398
+ if (params.identityStrategy === "provider-id") return createOAuthAccountIssuer(params.providerId);
399
+ const { accountIssuer } = params;
400
+ if (typeof accountIssuer === "function") {
401
+ const issuer = await accountIssuer(params.context);
402
+ return typeof issuer === "string" && issuer.trim().length > 0 ? issuer : void 0;
403
+ }
404
+ if (typeof accountIssuer === "string" && accountIssuer.trim().length > 0) return accountIssuer;
405
+ return createOAuthAccountIssuer(params.providerId);
406
+ }
377
407
  /** True when the installed better-auth build scopes accounts by issuer. */
378
408
  function supportsIssuerScopedAccounts(adapter) {
379
409
  return typeof adapter.findAccountByKey === "function";
@@ -408,7 +438,7 @@ function normalizeProviderSubject(subject) {
408
438
  if (!subject || subject === "undefined" || subject === "null") return;
409
439
  return subject;
410
440
  }
411
- async function resolveAccountKey(provider, tokens, profile) {
441
+ async function resolveAccountKey(provider, tokens, profile, identityStrategy) {
412
442
  const keyedProvider = provider;
413
443
  const context = {
414
444
  tokens,
@@ -424,7 +454,12 @@ async function resolveAccountKey(provider, tokens, profile) {
424
454
  }
425
455
  const accountId = rawSubject ? normalizeProviderSubject(rawSubject) : void 0;
426
456
  if (!accountId) return void 0;
427
- const issuer = typeof keyedProvider.accountIssuer === "function" ? await keyedProvider.accountIssuer(context) : keyedProvider.accountIssuer ?? createOAuthAccountIssuer(provider.id);
457
+ const issuer = await resolveOAuthAccountIssuer({
458
+ providerId: provider.id,
459
+ identityStrategy,
460
+ accountIssuer: keyedProvider.accountIssuer,
461
+ context
462
+ });
428
463
  if (!issuer) return void 0;
429
464
  return {
430
465
  issuer,
@@ -447,7 +482,8 @@ function instrumentSocialProviders(providers) {
447
482
  if (user) try {
448
483
  const endpointCtx = await getCurrentAuthContext();
449
484
  const adapter = endpointCtx.context.internalAdapter;
450
- const accountKey = result.data && typeof adapter.findAccountOwnerByKey === "function" ? await resolveAccountKey(provider, token, result.data) : void 0;
485
+ const identityStrategy = resolveAccountIdentityStrategy(endpointCtx.context.options.account);
486
+ const accountKey = result.data && typeof adapter.findAccountOwnerByKey === "function" ? await resolveAccountKey(provider, token, result.data, identityStrategy) : void 0;
451
487
  endpointCtx.context[OAUTH_CALLBACK_USER] = {
452
488
  user,
453
489
  accountKey
@@ -1053,6 +1089,49 @@ function resolveClientIpFromHeaders(headers, ipAddressHeaders) {
1053
1089
  * when a request includes an X-Request-Id header.
1054
1090
  */
1055
1091
  const IDENTIFICATION_COOKIE_NAME = "__infra-rid";
1092
+ /** GET routes that need identification (OAuth callbacks, verify links, etc.). */
1093
+ const IDENTIFICATION_GET_ROUTES = [
1094
+ routes.SIGN_IN_SOCIAL_CALLBACK,
1095
+ routes.SIGN_IN_OAUTH_CALLBACK,
1096
+ routes.DASH_IMPERSONATE_USER,
1097
+ routes.VERIFY_EMAIL,
1098
+ routes.MAGIC_LINK_VERIFY,
1099
+ routes.DASH_ACCEPT_INVITATION,
1100
+ routes.DASH_COMPLETE_INVITATION_SOCIAL
1101
+ ];
1102
+ /** GET OAuth callbacks where user creation can run without identify headers. */
1103
+ const IDENTIFICATION_OAUTH_CALLBACK_GET_ROUTES = [routes.SIGN_IN_SOCIAL_CALLBACK, routes.SIGN_IN_OAUTH_CALLBACK];
1104
+ /**
1105
+ * Whether identification middleware should run for this request.
1106
+ * Non-GET always runs; GET only for routes that need cookie/KV resolution.
1107
+ */
1108
+ function shouldRunIdentification(ctx, getRoutes = IDENTIFICATION_GET_ROUTES) {
1109
+ if (ctx.request?.method !== "GET") return true;
1110
+ return matchesAnyRoute(ctx.path, [...getRoutes]);
1111
+ }
1112
+ /**
1113
+ * Persist `X-Request-Id` as `__infra-rid` so redirect-flow OAuth callbacks
1114
+ * (GET, no identify headers) can resolve visitor identification from KV.
1115
+ * Must run in an after hook so Set-Cookie reaches the response.
1116
+ */
1117
+ function createIdentificationCookieAfterMiddleware() {
1118
+ return createAuthMiddleware(async (ctx) => {
1119
+ const headerRequestId = ctx.request?.headers.get("X-Request-Id");
1120
+ if (headerRequestId) {
1121
+ ctx.setCookie(IDENTIFICATION_COOKIE_NAME, headerRequestId, {
1122
+ maxAge: 600,
1123
+ sameSite: "lax",
1124
+ httpOnly: true,
1125
+ path: "/"
1126
+ });
1127
+ return;
1128
+ }
1129
+ if (ctx.context.requestId) ctx.setCookie(IDENTIFICATION_COOKIE_NAME, "", {
1130
+ maxAge: 0,
1131
+ path: "/"
1132
+ });
1133
+ });
1134
+ }
1056
1135
  const identificationCache = /* @__PURE__ */ new Map();
1057
1136
  const CACHE_TTL_MS = 6e4;
1058
1137
  const CACHE_MAX_SIZE = 1e3;
@@ -2465,7 +2544,7 @@ const sentinel = (options) => {
2465
2544
  hooks: {
2466
2545
  before: [
2467
2546
  {
2468
- matcher: (ctx) => ctx.request?.method !== "GET",
2547
+ matcher: (ctx) => shouldRunIdentification(ctx, IDENTIFICATION_OAUTH_CALLBACK_GET_ROUTES),
2469
2548
  handler: createIdentificationMiddleware($kv, {
2470
2549
  skipIdentification: (ctx) => isDashRoute(ctx.path),
2471
2550
  retry: opts.kvOptions.retry
@@ -2567,74 +2646,81 @@ const sentinel = (options) => {
2567
2646
  })
2568
2647
  }
2569
2648
  ],
2570
- after: [{
2571
- matcher: (ctx) => !!opts.security?.staleUsers?.enabled && !isDashRoute(ctx.path),
2572
- handler: createAuthMiddleware(async (ctx) => {
2573
- if (ctx.context.returned instanceof Error) return;
2574
- const created = ctx.context.newSession;
2575
- const userId = created?.user?.id ?? created?.session?.userId;
2576
- const sessionToken = created?.session?.token;
2577
- if (!userId || !sessionToken) return;
2578
- let user = created?.user ?? null;
2579
- try {
2580
- user = await getUserById(userId, ctx, { includeLastActiveAt: activityTrackingEnabled }) ?? user;
2581
- } catch (error) {
2582
- logger.warn("[Sentinel] Failed to fetch user for stale-account check:", error);
2649
+ after: [
2650
+ {
2651
+ matcher: (ctx) => shouldRunIdentification(ctx, IDENTIFICATION_OAUTH_CALLBACK_GET_ROUTES),
2652
+ handler: createIdentificationCookieAfterMiddleware()
2653
+ },
2654
+ {
2655
+ matcher: (ctx) => !!opts.security?.staleUsers?.enabled && !isDashRoute(ctx.path),
2656
+ handler: createAuthMiddleware(async (ctx) => {
2657
+ if (ctx.context.returned instanceof Error) return;
2658
+ const created = ctx.context.newSession;
2659
+ const userId = created?.user?.id ?? created?.session?.userId;
2660
+ const sessionToken = created?.session?.token;
2661
+ if (!userId || !sessionToken) return;
2662
+ let user = created?.user ?? null;
2663
+ try {
2664
+ user = await getUserById(userId, ctx, { includeLastActiveAt: activityTrackingEnabled }) ?? user;
2665
+ } catch (error) {
2666
+ logger.warn("[Sentinel] Failed to fetch user for stale-account check:", error);
2667
+ if (!user) return;
2668
+ }
2583
2669
  if (!user) return;
2584
- }
2585
- if (!user) return;
2586
- recordCheck(ctx, "stale_users");
2587
- const staleCheck = await securityService.checkStaleUser(userId, activityTrackingEnabled ? user.lastActiveAt ?? null : null);
2588
- if (!staleCheck.isStale) return;
2589
- const identification = ctx.context.identification;
2590
- const staleOpts = opts.security?.staleUsers;
2591
- const notificationPromises = [];
2592
- if (staleCheck.notifyUser && user.email) notificationPromises.push(securityService.notifyStaleAccountUser(user.email, user.name || null, staleCheck.daysSinceLastActive || 0, identification));
2593
- if (staleCheck.notifyAdmin && staleOpts?.adminEmail) notificationPromises.push(securityService.notifyStaleAccountAdmin(staleOpts.adminEmail, userId, user.email || "unknown", user.name || null, staleCheck.daysSinceLastActive || 0, identification));
2594
- if (notificationPromises.length > 0) Promise.all(notificationPromises).catch((error) => {
2595
- logger.error("[Sentinel] Failed to send stale account notifications:", error);
2596
- });
2597
- if (staleCheck.action !== "block") return;
2598
- setOutcome(ctx, "blocked", "stale_users", {
2599
- userId,
2600
- daysSinceLastActive: staleCheck.daysSinceLastActive,
2601
- staleDays: staleCheck.staleDays,
2602
- lastActiveAt: staleCheck.lastActiveAt,
2603
- notifyUser: staleCheck.notifyUser,
2604
- notifyAdmin: staleCheck.notifyAdmin
2605
- });
2606
- emitEvaluation(ctx, trackEvent);
2607
- try {
2608
- await ctx.context.internalAdapter.deleteSession(sessionToken);
2609
- } catch (error) {
2610
- logger.warn("[Sentinel] Failed to delete stale-blocked session:", error);
2611
- }
2612
- deleteSessionCookie(ctx);
2613
- ctx.context.setNewSession(null);
2614
- throw new APIError("FORBIDDEN", STALE_ACCOUNT_BLOCK_ERROR);
2615
- })
2616
- }, {
2617
- matcher: (ctx) => ctx.request?.method !== "GET" && !isDashRoute(ctx.path),
2618
- handler: createAuthMiddleware(async (ctx) => {
2619
- const untrustedVisitorId = ctx.context.untrustedVisitorId;
2620
- const ip = ctx.context.ip;
2621
- const body = ctx.body;
2622
- const loginId = matchesAnyRoute(ctx.path, [routes.SIGN_IN_USERNAME]) ? body?.username : body?.email;
2623
- const isPasswordSignInRoute = matchesAnyRoute(ctx.path, [
2624
- routes.SIGN_IN_EMAIL,
2625
- routes.SIGN_IN_USERNAME,
2626
- routes.SIGN_IN_EMAIL_OTP
2627
- ]);
2628
- emitEvaluation(ctx, trackEvent, {
2629
- identifier: loginId,
2630
- userAgent: ctx.headers?.get?.("user-agent") || ""
2631
- });
2632
- const returned = ctx.context.returned;
2633
- const staleBlocked = isStaleAccountError(returned);
2634
- if (isPasswordSignInRoute && returned instanceof Error && !staleBlocked && loginId && body?.password && untrustedVisitorId) await ctx.context.runInBackgroundOrAwait(securityService.trackFailedAttempt(loginId, untrustedVisitorId, body.password, ip, ctx.context.requestId ?? null));
2635
- if (isPasswordSignInRoute && (!(returned instanceof Error) || staleBlocked) && loginId) await ctx.context.runInBackgroundOrAwait(securityService.clearFailedAttempts(loginId));
2636
- })
2637
- }]
2670
+ recordCheck(ctx, "stale_users");
2671
+ const staleCheck = await securityService.checkStaleUser(userId, activityTrackingEnabled ? user.lastActiveAt ?? null : null);
2672
+ if (!staleCheck.isStale) return;
2673
+ const identification = ctx.context.identification;
2674
+ const staleOpts = opts.security?.staleUsers;
2675
+ const notificationPromises = [];
2676
+ if (staleCheck.notifyUser && user.email) notificationPromises.push(securityService.notifyStaleAccountUser(user.email, user.name || null, staleCheck.daysSinceLastActive || 0, identification));
2677
+ if (staleCheck.notifyAdmin && staleOpts?.adminEmail) notificationPromises.push(securityService.notifyStaleAccountAdmin(staleOpts.adminEmail, userId, user.email || "unknown", user.name || null, staleCheck.daysSinceLastActive || 0, identification));
2678
+ if (notificationPromises.length > 0) Promise.all(notificationPromises).catch((error) => {
2679
+ logger.error("[Sentinel] Failed to send stale account notifications:", error);
2680
+ });
2681
+ if (staleCheck.action !== "block") return;
2682
+ setOutcome(ctx, "blocked", "stale_users", {
2683
+ userId,
2684
+ daysSinceLastActive: staleCheck.daysSinceLastActive,
2685
+ staleDays: staleCheck.staleDays,
2686
+ lastActiveAt: staleCheck.lastActiveAt,
2687
+ notifyUser: staleCheck.notifyUser,
2688
+ notifyAdmin: staleCheck.notifyAdmin
2689
+ });
2690
+ emitEvaluation(ctx, trackEvent);
2691
+ try {
2692
+ await ctx.context.internalAdapter.deleteSession(sessionToken);
2693
+ } catch (error) {
2694
+ logger.warn("[Sentinel] Failed to delete stale-blocked session:", error);
2695
+ }
2696
+ deleteSessionCookie(ctx);
2697
+ ctx.context.setNewSession(null);
2698
+ throw new APIError("FORBIDDEN", STALE_ACCOUNT_BLOCK_ERROR);
2699
+ })
2700
+ },
2701
+ {
2702
+ matcher: (ctx) => ctx.request?.method !== "GET" && !isDashRoute(ctx.path),
2703
+ handler: createAuthMiddleware(async (ctx) => {
2704
+ const untrustedVisitorId = ctx.context.untrustedVisitorId;
2705
+ const ip = ctx.context.ip;
2706
+ const body = ctx.body;
2707
+ const loginId = matchesAnyRoute(ctx.path, [routes.SIGN_IN_USERNAME]) ? body?.username : body?.email;
2708
+ const isPasswordSignInRoute = matchesAnyRoute(ctx.path, [
2709
+ routes.SIGN_IN_EMAIL,
2710
+ routes.SIGN_IN_USERNAME,
2711
+ routes.SIGN_IN_EMAIL_OTP
2712
+ ]);
2713
+ emitEvaluation(ctx, trackEvent, {
2714
+ identifier: loginId,
2715
+ userAgent: ctx.headers?.get?.("user-agent") || ""
2716
+ });
2717
+ const returned = ctx.context.returned;
2718
+ const staleBlocked = isStaleAccountError(returned);
2719
+ if (isPasswordSignInRoute && returned instanceof Error && !staleBlocked && loginId && body?.password && untrustedVisitorId) await ctx.context.runInBackgroundOrAwait(securityService.trackFailedAttempt(loginId, untrustedVisitorId, body.password, ip, ctx.context.requestId ?? null));
2720
+ if (isPasswordSignInRoute && (!(returned instanceof Error) || staleBlocked) && loginId) await ctx.context.runInBackgroundOrAwait(securityService.clearFailedAttempts(loginId));
2721
+ })
2722
+ }
2723
+ ]
2638
2724
  }
2639
2725
  };
2640
2726
  };
@@ -2821,12 +2907,98 @@ function setCredentialResponseSecurityHeaders(ctx) {
2821
2907
  ctx.setHeader("Referrer-Policy", "no-referrer");
2822
2908
  }
2823
2909
  //#endregion
2824
- //#region src/directory-sync/pairing.ts
2825
- let samlPolicyModule;
2826
- function loadSAMLPolicy() {
2827
- samlPolicyModule ??= import("./saml-policy-DQR0MBLk.mjs");
2828
- return samlPolicyModule;
2910
+ //#region src/directory-sync/saml-policy.ts
2911
+ /**
2912
+ * Aligned with `@better-auth/sso` SAML metadata constants / helpers.
2913
+ * @see better-auth packages/sso parseSAMLServiceProviderMetadata
2914
+ */
2915
+ const SAML_METADATA_NAMESPACE = "urn:oasis:names:tc:SAML:2.0:metadata";
2916
+ const HTTP_POST_BINDING = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST";
2917
+ /**
2918
+ * Whether a SAML provider config requires signed assertions.
2919
+ *
2920
+ * Matches `@better-auth/sso` `deriveSAMLServiceProviderPolicy(...).wantAssertionsSigned`.
2921
+ * Invalid or unusable custom SP metadata throws (pairing maps this to BAD_REQUEST).
2922
+ */
2923
+ function requiresSignedSAMLAssertions(config) {
2924
+ const metadata = config.spMetadata?.metadata;
2925
+ if (!metadata) return config.wantAssertionsSigned === true;
2926
+ const parsedMetadata = parseSAMLServiceProviderMetadata(metadata);
2927
+ if (!parsedMetadata.postAssertionConsumerServiceUrls.length) throw new Error("Unusable SAML service provider metadata");
2928
+ return parsedMetadata.wantAssertionsSigned;
2829
2929
  }
2930
+ /**
2931
+ * Parses the security- and identity-relevant parts of SAML service-provider
2932
+ * metadata without relying on local-name-only XML matching.
2933
+ *
2934
+ * Port of `@better-auth/sso` `parseSAMLServiceProviderMetadata`.
2935
+ */
2936
+ function parseSAMLServiceProviderMetadata(metadata) {
2937
+ const document = new DOMParser({ onError: (_level, message) => {
2938
+ throw new Error(message);
2939
+ } }).parseFromString(metadata, "text/xml");
2940
+ const entityDescriptor = document.documentElement;
2941
+ if (!entityDescriptor || entityDescriptor.localName !== "EntityDescriptor" || entityDescriptor.namespaceURI !== SAML_METADATA_NAMESPACE) throw new Error("Invalid SAML EntityDescriptor");
2942
+ const entityID = entityDescriptor.getAttribute("entityID")?.trim();
2943
+ if (!entityID) throw new Error("Missing SAML entityID");
2944
+ const serviceProviderDescriptors = directMetadataChildren(entityDescriptor, "SPSSODescriptor");
2945
+ if (serviceProviderDescriptors.length === 0) throw new Error("SAML metadata must contain an SPSSODescriptor");
2946
+ const acceptedServiceProviderDescriptors = new Set(serviceProviderDescriptors);
2947
+ for (const element of Array.from(document.getElementsByTagName("*"))) {
2948
+ if (element.localName === "EntityDescriptor" && element !== entityDescriptor) throw new Error("Invalid nested SAML EntityDescriptor");
2949
+ if (element.localName === "SPSSODescriptor" && !acceptedServiceProviderDescriptors.has(element)) throw new Error("Invalid SAML SPSSODescriptor namespace or position");
2950
+ if ((element.localName === "AssertionConsumerService" || element.localName === "NameIDFormat") && (element.namespaceURI !== SAML_METADATA_NAMESPACE || !element.parentNode || !isElement(element.parentNode) || !acceptedServiceProviderDescriptors.has(element.parentNode))) throw new Error(`Invalid SAML ${element.localName} namespace or position`);
2951
+ }
2952
+ const postAssertionConsumerServiceUrls = [];
2953
+ const nameIDFormats = [];
2954
+ let wantAssertionsSigned = false;
2955
+ for (const descriptor of serviceProviderDescriptors) {
2956
+ wantAssertionsSigned = parseXMLSchemaBoolean(descriptor.getAttribute("WantAssertionsSigned")) || wantAssertionsSigned;
2957
+ for (const nameIDFormat of directMetadataChildren(descriptor, "NameIDFormat")) {
2958
+ const value = nameIDFormat.textContent?.trim();
2959
+ if (value) nameIDFormats.push(value);
2960
+ }
2961
+ for (const service of directMetadataChildren(descriptor, "AssertionConsumerService")) {
2962
+ if (service.getAttribute("Binding") !== HTTP_POST_BINDING) continue;
2963
+ const location = service.getAttribute("Location")?.trim();
2964
+ if (!location || !isAbsoluteHttpUrl(location)) throw new Error("Invalid SAML POST AssertionConsumerService");
2965
+ postAssertionConsumerServiceUrls.push(location);
2966
+ }
2967
+ }
2968
+ return {
2969
+ entityID,
2970
+ nameIDFormats: [...new Set(nameIDFormats)],
2971
+ postAssertionConsumerServiceUrls: [...new Set(postAssertionConsumerServiceUrls)],
2972
+ wantAssertionsSigned
2973
+ };
2974
+ }
2975
+ function isElement(node) {
2976
+ return node.nodeType === 1;
2977
+ }
2978
+ function directMetadataChildren(element, localName) {
2979
+ return Array.from(element.childNodes).filter((node) => isElement(node) && node.localName === localName && node.namespaceURI === SAML_METADATA_NAMESPACE);
2980
+ }
2981
+ function parseXMLSchemaBoolean(value) {
2982
+ if (value === null) return false;
2983
+ switch (value.trim()) {
2984
+ case "true":
2985
+ case "1": return true;
2986
+ case "false":
2987
+ case "0": return false;
2988
+ default: throw new Error("Invalid XML Schema boolean");
2989
+ }
2990
+ }
2991
+ function isAbsoluteHttpUrl(value) {
2992
+ if (value.includes("#")) return false;
2993
+ try {
2994
+ const url = new URL(value);
2995
+ return url.protocol === "http:" || url.protocol === "https:";
2996
+ } catch {
2997
+ return false;
2998
+ }
2999
+ }
3000
+ //#endregion
3001
+ //#region src/directory-sync/pairing.ts
2830
3002
  function isRecord$1(value) {
2831
3003
  return typeof value === "object" && value !== null && !Array.isArray(value);
2832
3004
  }
@@ -2879,10 +3051,9 @@ async function resolveDirectorySyncSSOPairing(ctx, organizationId, pairing) {
2879
3051
  if (!oidcConfiguration || samlConfiguration) throw ctx.error("BAD_REQUEST", { message: "The selected SSO provider is not an OIDC provider" });
2880
3052
  } else {
2881
3053
  if (!samlConfiguration || oidcConfiguration) throw ctx.error("BAD_REQUEST", { message: "The selected SSO provider is not a SAML provider" });
2882
- const samlPolicy = await loadSAMLPolicy();
2883
3054
  let wantAssertionsSigned = false;
2884
3055
  try {
2885
- wantAssertionsSigned = await samlPolicy.requiresSignedSAMLAssertions({
3056
+ wantAssertionsSigned = requiresSignedSAMLAssertions({
2886
3057
  spMetadata: isRecord$1(samlConfiguration.spMetadata) ? { metadata: typeof samlConfiguration.spMetadata.metadata === "string" ? samlConfiguration.spMetadata.metadata : void 0 } : void 0,
2887
3058
  wantAssertionsSigned: samlConfiguration.wantAssertionsSigned === true
2888
3059
  });
@@ -2929,17 +3100,228 @@ async function guardDirectorySyncSSOProviderMutation(input, context) {
2929
3100
  }
2930
3101
  }
2931
3102
  //#endregion
3103
+ //#region src/directory-sync/scim-user-link.ts
3104
+ function createScopedKey(parts) {
3105
+ return base64Url.encode(sha256(new TextEncoder().encode(JSON.stringify(parts))), { padding: false });
3106
+ }
3107
+ function createSCIMUserExternalIdKey(connectionId, externalId) {
3108
+ return createScopedKey([
3109
+ "scim-user-external-id",
3110
+ connectionId,
3111
+ externalId
3112
+ ]);
3113
+ }
3114
+ function createSCIMConnectionKey(connectionId) {
3115
+ return createScopedKey(["scim-connection", connectionId]);
3116
+ }
3117
+ var SCIMIdentityMutationConflict = class extends Error {
3118
+ constructor() {
3119
+ super("The SCIM identity changed concurrently; retry the request");
3120
+ this.name = "SCIMIdentityMutationConflict";
3121
+ }
3122
+ };
3123
+ function concurrentIdentityMutation() {
3124
+ throw new SCIMIdentityMutationConflict();
3125
+ }
3126
+ function hasIncrementOne(database) {
3127
+ return typeof database.incrementOne === "function";
3128
+ }
3129
+ async function tryFenceActiveSCIMConnection(database, connectionId) {
3130
+ return database.incrementOne({
3131
+ model: "scimConnectionBinding",
3132
+ where: [
3133
+ {
3134
+ field: "connectionKey",
3135
+ value: createSCIMConnectionKey(connectionId)
3136
+ },
3137
+ {
3138
+ field: "connectionId",
3139
+ value: connectionId
3140
+ },
3141
+ {
3142
+ field: "decommissionStatus",
3143
+ value: "active"
3144
+ }
3145
+ ],
3146
+ increment: { decommissionRevision: 1 }
3147
+ });
3148
+ }
3149
+ /**
3150
+ * Acquires an active provisioned User link inside the caller's transaction.
3151
+ * Returns null when no active link exists. Throws on concurrent identity mutation.
3152
+ */
3153
+ async function acquireActiveSCIMUserLink(reference, context) {
3154
+ if (!hasIncrementOne(context.database)) return null;
3155
+ const database = context.database;
3156
+ const externalIdKey = createSCIMUserExternalIdKey(reference.connectionId, reference.externalId);
3157
+ const source = await database.findOne({
3158
+ model: "scimUser",
3159
+ where: [
3160
+ {
3161
+ field: "connectionId",
3162
+ value: reference.connectionId
3163
+ },
3164
+ {
3165
+ field: "externalIdKey",
3166
+ value: externalIdKey
3167
+ },
3168
+ {
3169
+ field: "externalId",
3170
+ value: reference.externalId
3171
+ },
3172
+ {
3173
+ field: "active",
3174
+ value: true
3175
+ }
3176
+ ]
3177
+ });
3178
+ if (!source) return null;
3179
+ const binding = await database.findOne({
3180
+ model: "scimConnectionBinding",
3181
+ where: [
3182
+ {
3183
+ field: "connectionKey",
3184
+ value: createSCIMConnectionKey(reference.connectionId)
3185
+ },
3186
+ {
3187
+ field: "connectionId",
3188
+ value: reference.connectionId
3189
+ },
3190
+ {
3191
+ field: "decommissionStatus",
3192
+ value: "active"
3193
+ }
3194
+ ]
3195
+ });
3196
+ if (!binding || binding.provisioningDomainId !== source.provisioningDomainId) return null;
3197
+ if (await database.findOne({
3198
+ model: "scimIdentityTombstone",
3199
+ where: [
3200
+ {
3201
+ field: "connectionId",
3202
+ value: reference.connectionId
3203
+ },
3204
+ {
3205
+ field: "externalIdKey",
3206
+ value: externalIdKey
3207
+ },
3208
+ {
3209
+ field: "externalId",
3210
+ value: reference.externalId
3211
+ }
3212
+ ]
3213
+ })) return null;
3214
+ const subject = await database.findOne({
3215
+ model: "scimSubject",
3216
+ where: [{
3217
+ field: "userId",
3218
+ value: source.userId
3219
+ }]
3220
+ });
3221
+ if (!subject) return null;
3222
+ if (!await database.findOne({
3223
+ model: "user",
3224
+ where: [{
3225
+ field: "id",
3226
+ value: source.userId
3227
+ }]
3228
+ })) return null;
3229
+ const acquiredSubject = await database.incrementOne({
3230
+ model: "scimSubject",
3231
+ where: [
3232
+ {
3233
+ field: "id",
3234
+ value: subject.id
3235
+ },
3236
+ {
3237
+ field: "userId",
3238
+ value: source.userId
3239
+ },
3240
+ {
3241
+ field: "revision",
3242
+ value: subject.revision
3243
+ }
3244
+ ],
3245
+ increment: { revision: 1 },
3246
+ set: { updatedAt: /* @__PURE__ */ new Date() }
3247
+ });
3248
+ if (!acquiredSubject) concurrentIdentityMutation();
3249
+ const acquiredSource = await database.findOne({
3250
+ model: "scimUser",
3251
+ where: [
3252
+ {
3253
+ field: "id",
3254
+ value: source.id
3255
+ },
3256
+ {
3257
+ field: "connectionId",
3258
+ value: reference.connectionId
3259
+ },
3260
+ {
3261
+ field: "provisioningDomainId",
3262
+ value: binding.provisioningDomainId
3263
+ },
3264
+ {
3265
+ field: "userId",
3266
+ value: source.userId
3267
+ },
3268
+ {
3269
+ field: "connectionUserKey",
3270
+ value: source.connectionUserKey
3271
+ },
3272
+ {
3273
+ field: "externalIdKey",
3274
+ value: externalIdKey
3275
+ },
3276
+ {
3277
+ field: "externalId",
3278
+ value: reference.externalId
3279
+ },
3280
+ {
3281
+ field: "active",
3282
+ value: true
3283
+ }
3284
+ ]
3285
+ });
3286
+ if (!acquiredSource || !acquiredSource.active || acquiredSource.userId !== acquiredSubject.userId) concurrentIdentityMutation();
3287
+ if (!await database.findOne({
3288
+ model: "user",
3289
+ where: [{
3290
+ field: "id",
3291
+ value: acquiredSource.userId
3292
+ }]
3293
+ })) concurrentIdentityMutation();
3294
+ if (await database.findOne({
3295
+ model: "scimIdentityTombstone",
3296
+ where: [
3297
+ {
3298
+ field: "connectionId",
3299
+ value: reference.connectionId
3300
+ },
3301
+ {
3302
+ field: "externalIdKey",
3303
+ value: externalIdKey
3304
+ },
3305
+ {
3306
+ field: "externalId",
3307
+ value: reference.externalId
3308
+ }
3309
+ ]
3310
+ })) concurrentIdentityMutation();
3311
+ const acquiredBinding = await tryFenceActiveSCIMConnection(database, reference.connectionId);
3312
+ if (!acquiredBinding || acquiredBinding.id !== binding.id || acquiredBinding.provisioningDomainId !== acquiredSource.provisioningDomainId) concurrentIdentityMutation();
3313
+ return {
3314
+ scimUserId: source.id,
3315
+ userId: source.userId
3316
+ };
3317
+ }
3318
+ //#endregion
2932
3319
  //#region src/directory-sync/sso-user-resolution.ts
2933
3320
  const GENERIC_REJECTION = {
2934
3321
  action: "reject",
2935
3322
  code: "DIRECTORY_SYNC_AUTHENTICATION_FAILED",
2936
3323
  message: "Unable to sign in with this SSO connection"
2937
3324
  };
2938
- let scimCatalogModule;
2939
- function loadSCIMCatalog() {
2940
- scimCatalogModule ??= import("@better-auth/scim");
2941
- return scimCatalogModule;
2942
- }
2943
3325
  function readStringExternalId(value) {
2944
3326
  if (typeof value === "string" && value.length > 0) return value;
2945
3327
  return null;
@@ -2985,9 +3367,7 @@ async function resolveOrganizationDirectorySyncUser(input, context) {
2985
3367
  const externalId = readExternalId(input, pairing);
2986
3368
  if (!externalId) return GENERIC_REJECTION;
2987
3369
  try {
2988
- const catalog = await loadSCIMCatalog();
2989
- if (typeof catalog.acquireActiveSCIMUserLink !== "function") return GENERIC_REJECTION;
2990
- const link = await catalog.acquireActiveSCIMUserLink({
3370
+ const link = await acquireActiveSCIMUserLink({
2991
3371
  connectionId: directory.connectionId,
2992
3372
  externalId
2993
3373
  }, { database: context.database });
@@ -3911,6 +4291,7 @@ const getConfig = (options) => {
3911
4291
  disableCSRFCheck: ctx.context.options.advanced?.disableCSRFCheck || false,
3912
4292
  disableOriginCheck: ctx.context.options.advanced?.disableOriginCheck || false,
3913
4293
  allowDifferentEmails: ctx.context.options.account?.accountLinking?.enabled && ctx.context.options.account?.accountLinking?.allowDifferentEmails || false,
4294
+ identityStrategy: getConfiguredAccountIdentityStrategy(ctx.context.options.account),
3914
4295
  skipStateCookieCheck: ctx.context.options.account?.skipStateCookieCheck || false,
3915
4296
  storeStateCookieStrategy: ctx.context.options.account?.storeStateStrategy || null,
3916
4297
  cookieCache: {
@@ -4336,14 +4717,6 @@ async function recoverManagedDirectoryConnection(ctx, row, input, policy) {
4336
4717
  async function createManagedDirectoryConnection(ctx, input, policy) {
4337
4718
  await assertManagedDirectoryTransactionsConfigured(ctx);
4338
4719
  if (input.pairing) await assertDirectorySyncSSOIntegrationConfigured(ctx);
4339
- if (input.pairing?.protocol === "saml") try {
4340
- await loadSAMLPolicy();
4341
- } catch {
4342
- throw ctx.error("NOT_IMPLEMENTED", {
4343
- code: "DIRECTORY_SYNC_SAML_POLICY_UNAVAILABLE",
4344
- message: "SAML pairing requires a compatible SSO package with service provider metadata policy support"
4345
- });
4346
- }
4347
4720
  const aliasKey = await createAliasKey(input.organizationId, input.providerId);
4348
4721
  const provisioningDomainId = await createProvisioningDomainId(input.organizationId, input.providerId);
4349
4722
  const activeOrganizationKey = await createActiveOrganizationKey(input.organizationId);
@@ -10488,18 +10861,7 @@ const dash = (options) => {
10488
10861
  },
10489
10862
  hooks: {
10490
10863
  before: [{
10491
- matcher: (ctx) => {
10492
- if (ctx.request?.method !== "GET") return true;
10493
- return matchesAnyRoute(ctx.path, [
10494
- routes.SIGN_IN_SOCIAL_CALLBACK,
10495
- routes.SIGN_IN_OAUTH_CALLBACK,
10496
- routes.DASH_IMPERSONATE_USER,
10497
- routes.VERIFY_EMAIL,
10498
- routes.MAGIC_LINK_VERIFY,
10499
- routes.DASH_ACCEPT_INVITATION,
10500
- routes.DASH_COMPLETE_INVITATION_SOCIAL
10501
- ]);
10502
- },
10864
+ matcher: (ctx) => shouldRunIdentification(ctx, IDENTIFICATION_GET_ROUTES),
10503
10865
  handler: createIdentificationMiddleware($kv, {
10504
10866
  skipIdentification: (ctx) => isDashRoute(ctx.path),
10505
10867
  retry: opts.kvOptions.retry
@@ -10532,19 +10894,12 @@ const dash = (options) => {
10532
10894
  if (matchesAnyRoute(ctx.path, [routes.SIGN_IN_EMAIL, routes.SIGN_IN_EMAIL_OTP]) && ctx.context.returned instanceof Error && body?.email) trackEmailSignInAttempt(ctx, trigger, ctx.context.location);
10533
10895
  if (matchesAnyRoute(ctx.path, [routes.SIGN_IN_SOCIAL]) && ctx.context.returned instanceof Error && ctx.body.provider && ctx.body.idToken) trackSocialSignInAttempt(ctx, trigger, ctx.context.location);
10534
10896
  if (matchesAnyRoute(ctx.path, [routes.SIGN_IN_SOCIAL_CALLBACK, routes.SIGN_IN_OAUTH_CALLBACK]) && ctx.request?.method === "GET" && !ctx.context.newSession) trackSocialSignInRedirectionAttempt(ctx, trigger, ctx.context.location);
10535
- const headerRequestId = ctx.request?.headers.get("X-Request-Id");
10536
- if (headerRequestId) ctx.setCookie(IDENTIFICATION_COOKIE_NAME, headerRequestId, {
10537
- maxAge: 600,
10538
- sameSite: "lax",
10539
- httpOnly: true,
10540
- path: "/"
10541
- });
10542
- else if (ctx.context.requestId) ctx.setCookie(IDENTIFICATION_COOKIE_NAME, "", {
10543
- maxAge: 0,
10544
- path: "/"
10545
- });
10546
10897
  })
10547
10898
  },
10899
+ {
10900
+ matcher: (ctx) => shouldRunIdentification(ctx, IDENTIFICATION_GET_ROUTES),
10901
+ handler: createIdentificationCookieAfterMiddleware()
10902
+ },
10548
10903
  {
10549
10904
  handler: createAuthMiddleware(async (ctx) => {
10550
10905
  if (!opts.activityTracking?.enabled) return;
package/dist/native.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { o as createKV, t as bytesToHex } from "./crypto-CV91nSbp.mjs";
2
- import { a as resolveSentinelClientIdentifyUrl, c as identify, i as solvePoWChallenge, l as dashClient, n as decodePoWChallenge, o as resolveIdentifyClientRetry, r as encodePoWSolution, t as createPowRetryTimeout } from "./pow-retry-7sPi32Ow.mjs";
1
+ import { o as createKV, t as bytesToHex } from "./crypto-D3yZH0Cy.mjs";
2
+ import { a as resolveSentinelClientIdentifyUrl, c as identify, i as solvePoWChallenge, l as dashClient, n as decodePoWChallenge, o as resolveIdentifyClientRetry, r as encodePoWSolution, t as createPowRetryTimeout } from "./pow-retry-jWGI__oh.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-CV91nSbp.mjs";
1
+ import { i as randomBytes, n as hash, t as bytesToHex } from "./crypto-D3yZH0Cy.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.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "Dashboard and analytics plugin for Better Auth",
5
5
  "type": "module",
6
6
  "main": "dist/index.mjs",
@@ -70,7 +70,7 @@
70
70
  "@types/bun": "latest",
71
71
  "@types/node": "^24.12.0",
72
72
  "better-auth": "1.7.0-rc.1",
73
- "expo-crypto": "^14.0.2",
73
+ "expo-crypto": "^57.0.1",
74
74
  "happy-dom": "^20.9.0",
75
75
  "msw": "^2.14.6",
76
76
  "tsdown": "^0.22.14",
@@ -81,9 +81,10 @@
81
81
  "@better-auth/utils": "^0.4.2",
82
82
  "@better-fetch/fetch": "1.3.1",
83
83
  "@noble/hashes": "^2.2.0",
84
+ "@xmldom/xmldom": "^0.9.10",
84
85
  "better-call": "1.3.7",
85
86
  "jose": "^6.2.4",
86
- "libphonenumber-js": "^1.13.9"
87
+ "libphonenumber-js": "^1.13.11"
87
88
  },
88
89
  "peerDependencies": {
89
90
  "better-auth": ">=1.4.0",
@@ -1,12 +0,0 @@
1
- //#region src/directory-sync/saml-policy.ts
2
- /**
3
- * `deriveSAMLServiceProviderPolicy` ships in `@better-auth/sso` 1.7.0+.
4
- * Load it at runtime so typecheck against older matrix versions still succeeds.
5
- */
6
- async function requiresSignedSAMLAssertions(config) {
7
- const sso = await import("@better-auth/sso");
8
- if (typeof sso.deriveSAMLServiceProviderPolicy === "function") return sso.deriveSAMLServiceProviderPolicy(config).wantAssertionsSigned;
9
- return config.wantAssertionsSigned === true;
10
- }
11
- //#endregion
12
- export { requiresSignedSAMLAssertions };