@better-auth/sso 1.6.15 → 1.6.16

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 CHANGED
@@ -1,4 +1,4 @@
1
- import { t as PACKAGE_VERSION } from "./version-BPpah8cV.mjs";
1
+ import { t as PACKAGE_VERSION } from "./version-KncHedVM.mjs";
2
2
  //#region src/client.ts
3
3
  const ssoClient = (options) => {
4
4
  return {
package/dist/index.mjs CHANGED
@@ -1,11 +1,11 @@
1
- import { t as PACKAGE_VERSION } from "./version-BPpah8cV.mjs";
1
+ import { t as PACKAGE_VERSION } from "./version-KncHedVM.mjs";
2
2
  import { APIError, createAuthEndpoint, createAuthMiddleware, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
3
3
  import { XMLParser, XMLValidator } from "fast-xml-parser";
4
4
  import { X509Certificate } from "node:crypto";
5
5
  import { getHostname } from "tldts";
6
6
  import { generateRandomString } from "better-auth/crypto";
7
7
  import * as z from "zod";
8
- import { isPublicRoutableHost } from "@better-auth/core/utils/host";
8
+ import { classifyHost, isPublicRoutableHost } from "@better-auth/core/utils/host";
9
9
  import { BetterFetchError, betterFetch } from "@better-fetch/fetch";
10
10
  import { base64 } from "@better-auth/utils/base64";
11
11
  import { isAPIError } from "@better-auth/core/utils/is-api-error";
@@ -527,6 +527,75 @@ function validateSkipDiscoveryEndpoints(config, isTrustedOrigin) {
527
527
  for (const [name, url] of fields) if (url) validateSkipDiscoveryEndpoint(name, url, isTrustedOrigin);
528
528
  }
529
529
  /**
530
+ * Re-validate an endpoint by resolving its hostname and rejecting any resolved
531
+ * address that is not publicly routable.
532
+ *
533
+ * {@link validateSkipDiscoveryEndpoint} only classifies the literal hostname, so
534
+ * a host like `idp.example` whose DNS record points at `127.0.0.1`,
535
+ * `169.254.169.254`, or an RFC 1918 address passes that check unchanged. This
536
+ * function closes that gap by performing the same RFC 6890 classification on the
537
+ * addresses the host actually resolves to, right before the server-side fetch.
538
+ *
539
+ * Best-effort by design:
540
+ * - Operator-allowlisted origins (trustedOrigins) are skipped — this is the
541
+ * documented escape hatch for internal IdPs.
542
+ * - IP-literal hosts are already fully covered by the synchronous check.
543
+ * - On runtimes without `node:dns` (e.g. Cloudflare Workers / edge), DNS
544
+ * resolution is unavailable; we fall back to the synchronous host check and
545
+ * the platform's own egress controls.
546
+ *
547
+ * Note: this resolves once and validates the result; it does not pin the address
548
+ * for the subsequent connection, so a change in the resolved address between
549
+ * this lookup and the fetch remains theoretically possible. It nonetheless
550
+ * rejects the common case of a DNS record that statically points at an internal
551
+ * address.
552
+ *
553
+ * @throws DiscoveryError(discovery_private_host) if any resolved address is not public
554
+ */
555
+ async function assertEndpointResolvesPublic(name, endpoint, isTrustedOrigin) {
556
+ const parsed = parseURL(name, endpoint);
557
+ if (isTrustedOrigin(parsed.toString())) return;
558
+ const host = parsed.hostname;
559
+ if (classifyHost(host).literal !== "fqdn") return;
560
+ let dns;
561
+ try {
562
+ dns = await import("node:dns/promises");
563
+ } catch {
564
+ return;
565
+ }
566
+ let resolved;
567
+ try {
568
+ resolved = await dns.lookup(host, { all: true });
569
+ } catch {
570
+ return;
571
+ }
572
+ for (const { address } of resolved) if (!isPublicRoutableHost(address)) throw new DiscoveryError("discovery_private_host", `The ${name} host "${host}" resolves to a non-publicly-routable address (${address}). If this is an internal IdP, add its origin to trustedOrigins.`, {
573
+ endpoint: name,
574
+ url: endpoint,
575
+ hostname: host,
576
+ resolved: address
577
+ });
578
+ }
579
+ /**
580
+ * Re-validate, at fetch time, every OIDC endpoint that is fetched server-side
581
+ * (token, userinfo, jwks). Runs the synchronous host classification plus the
582
+ * best-effort DNS resolution check. `authorizationEndpoint` is intentionally
583
+ * excluded — it is a browser redirect target, not a server-side fetch, so these
584
+ * checks don't apply to it.
585
+ */
586
+ async function assertOIDCEndpointsResolvePublic(config, isTrustedOrigin) {
587
+ const fields = [
588
+ ["tokenEndpoint", config.tokenEndpoint],
589
+ ["userInfoEndpoint", config.userInfoEndpoint],
590
+ ["jwksEndpoint", config.jwksEndpoint]
591
+ ];
592
+ for (const [name, url] of fields) {
593
+ if (!url) continue;
594
+ validateSkipDiscoveryEndpoint(name, url, isTrustedOrigin);
595
+ await assertEndpointResolvesPublic(name, url, isTrustedOrigin);
596
+ }
597
+ }
598
+ /**
530
599
  * Fetch the OIDC discovery document from the IdP.
531
600
  *
532
601
  * @param url - The discovery endpoint URL
@@ -538,7 +607,8 @@ async function fetchDiscoveryDocument(url, timeout = DEFAULT_DISCOVERY_TIMEOUT)
538
607
  try {
539
608
  const response = await betterFetch(url, {
540
609
  method: "GET",
541
- timeout
610
+ timeout,
611
+ redirect: "error"
542
612
  });
543
613
  if (response.error) {
544
614
  const { status } = response.error;
@@ -706,20 +776,24 @@ function needsRuntimeDiscovery(config) {
706
776
  * Throws if discovery fails.
707
777
  */
708
778
  async function ensureRuntimeDiscovery(config, issuer, isTrustedOrigin) {
709
- if (!needsRuntimeDiscovery(config)) return config;
710
- const hydrated = await discoverOIDCConfig({
711
- issuer,
712
- existingConfig: config,
713
- isTrustedOrigin
714
- });
715
- return {
716
- ...config,
717
- authorizationEndpoint: hydrated.authorizationEndpoint,
718
- tokenEndpoint: hydrated.tokenEndpoint,
719
- tokenEndpointAuthentication: hydrated.tokenEndpointAuthentication,
720
- userInfoEndpoint: hydrated.userInfoEndpoint,
721
- jwksEndpoint: hydrated.jwksEndpoint
722
- };
779
+ let resolved = config;
780
+ if (needsRuntimeDiscovery(config)) {
781
+ const hydrated = await discoverOIDCConfig({
782
+ issuer,
783
+ existingConfig: config,
784
+ isTrustedOrigin
785
+ });
786
+ resolved = {
787
+ ...config,
788
+ authorizationEndpoint: hydrated.authorizationEndpoint,
789
+ tokenEndpoint: hydrated.tokenEndpoint,
790
+ tokenEndpointAuthentication: hydrated.tokenEndpointAuthentication,
791
+ userInfoEndpoint: hydrated.userInfoEndpoint,
792
+ jwksEndpoint: hydrated.jwksEndpoint
793
+ };
794
+ }
795
+ await assertOIDCEndpointsResolvePublic(resolved, isTrustedOrigin);
796
+ return resolved;
723
797
  }
724
798
  //#endregion
725
799
  //#region src/oidc/errors.ts
@@ -1727,11 +1801,10 @@ async function processSAMLResponse(ctx, params, options) {
1727
1801
  if (options?.saml?.enableInResponseToValidation !== false) {
1728
1802
  const allowIdpInitiated = options?.saml?.allowIdpInitiated !== false;
1729
1803
  if (inResponseTo) {
1804
+ const consumed = await ctx.context.internalAdapter.consumeVerificationValue(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
1730
1805
  let storedRequest = null;
1731
- const verification = await ctx.context.internalAdapter.findVerificationValue(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
1732
- if (verification) try {
1733
- storedRequest = JSON.parse(verification.value);
1734
- if (storedRequest && storedRequest.expiresAt < Date.now()) storedRequest = null;
1806
+ if (consumed) try {
1807
+ storedRequest = JSON.parse(consumed.value);
1735
1808
  } catch {
1736
1809
  storedRequest = null;
1737
1810
  }
@@ -1748,10 +1821,8 @@ async function processSAMLResponse(ctx, params, options) {
1748
1821
  expectedProvider: storedRequest.providerId,
1749
1822
  actualProvider: providerId
1750
1823
  });
1751
- await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
1752
1824
  throw ctx.redirect(`${samlRedirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`);
1753
1825
  }
1754
- await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
1755
1826
  } else if (!allowIdpInitiated) {
1756
1827
  ctx.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId });
1757
1828
  throw ctx.redirect(`${samlRedirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
@@ -1816,7 +1887,7 @@ async function processSAMLResponse(ctx, params, options) {
1816
1887
  });
1817
1888
  throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
1818
1889
  }
1819
- const isTrustedProvider = ctx.context.trustedProviders.includes(providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
1890
+ const isTrustedProvider = "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
1820
1891
  const callbackUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1821
1892
  const errorUrl = relayState?.errorURL || samlRedirectUrl;
1822
1893
  let result;
@@ -1836,7 +1907,8 @@ async function processSAMLResponse(ctx, params, options) {
1836
1907
  },
1837
1908
  callbackURL: callbackUrl,
1838
1909
  disableSignUp: options?.disableImplicitSignUp,
1839
- isTrustedProvider
1910
+ isTrustedProvider,
1911
+ trustProviderByName: false
1840
1912
  });
1841
1913
  } catch (e) {
1842
1914
  if (isAPIError(e) && e.body?.code) {
@@ -2227,6 +2299,14 @@ const registerSSOProvider = (options) => {
2227
2299
  if (!member) throw new APIError("BAD_REQUEST", { message: "You are not a member of the organization" });
2228
2300
  if (ctx.context.hasPlugin("organization") && !hasOrgAdminRole(member)) throw new APIError("FORBIDDEN", { message: "You must be an organization owner or admin to register SSO providers" });
2229
2301
  }
2302
+ if (new Set([
2303
+ "credential",
2304
+ ...ctx.context.socialProviders.map((p) => p.id),
2305
+ ...ctx.context.trustedProviders
2306
+ ]).has(body.providerId)) {
2307
+ ctx.context.logger.warn(`SSO provider registration rejected for reserved providerId: ${body.providerId}`);
2308
+ throw new APIError("UNPROCESSABLE_ENTITY", { message: "This providerId is reserved and cannot be used for an SSO provider" });
2309
+ }
2230
2310
  if (await ctx.context.adapter.findOne({
2231
2311
  model: "ssoProvider",
2232
2312
  where: [{
@@ -2687,7 +2767,10 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
2687
2767
  let userInfo = null;
2688
2768
  const mapping = config.mapping || {};
2689
2769
  if (config.userInfoEndpoint) {
2690
- const userInfoResponse = await betterFetch(config.userInfoEndpoint, { headers: { Authorization: `Bearer ${tokenResponse.accessToken}` } });
2770
+ const userInfoResponse = await betterFetch(config.userInfoEndpoint, {
2771
+ headers: { Authorization: `Bearer ${tokenResponse.accessToken}` },
2772
+ redirect: "error"
2773
+ });
2691
2774
  if (userInfoResponse.error) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=${userInfoResponse.error.message}`);
2692
2775
  const rawUserInfo = userInfoResponse.data;
2693
2776
  userInfo = {
@@ -2743,7 +2826,8 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
2743
2826
  callbackURL,
2744
2827
  disableSignUp: options?.disableImplicitSignUp && !requestSignUp,
2745
2828
  overrideUserInfo: config.overrideUserInfo,
2746
- isTrustedProvider
2829
+ isTrustedProvider,
2830
+ trustProviderByName: false
2747
2831
  });
2748
2832
  } catch (e) {
2749
2833
  if (isAPIError(e) && e.body?.code) {
@@ -1,5 +1,5 @@
1
1
  //#endregion
2
2
  //#region src/version.ts
3
- const PACKAGE_VERSION = "1.6.15";
3
+ const PACKAGE_VERSION = "1.6.16";
4
4
  //#endregion
5
5
  export { PACKAGE_VERSION as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@better-auth/sso",
3
- "version": "1.6.15",
3
+ "version": "1.6.16",
4
4
  "description": "SSO plugin for Better Auth",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -65,20 +65,20 @@
65
65
  "devDependencies": {
66
66
  "@types/body-parser": "^1.19.6",
67
67
  "@types/express": "^5.0.6",
68
- "better-call": "1.3.5",
68
+ "better-call": "1.3.6",
69
69
  "body-parser": "^2.2.2",
70
70
  "express": "^5.2.1",
71
71
  "oauth2-mock-server": "^8.2.2",
72
72
  "tsdown": "0.21.1",
73
- "@better-auth/core": "1.6.15",
74
- "better-auth": "1.6.15"
73
+ "@better-auth/core": "1.6.16",
74
+ "better-auth": "1.6.16"
75
75
  },
76
76
  "peerDependencies": {
77
77
  "@better-auth/utils": "0.4.1",
78
- "@better-fetch/fetch": "1.1.21",
79
- "better-call": "1.3.5",
80
- "@better-auth/core": "^1.6.15",
81
- "better-auth": "^1.6.15"
78
+ "@better-fetch/fetch": "1.2.2",
79
+ "better-call": "1.3.6",
80
+ "@better-auth/core": "^1.6.16",
81
+ "better-auth": "^1.6.16"
82
82
  },
83
83
  "scripts": {
84
84
  "build": "tsdown",