@better-auth/sso 1.6.14 → 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-ekzjMTDh.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-ekzjMTDh.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
@@ -1538,7 +1612,8 @@ function createSP(config, baseURL, providerId, opts) {
1538
1612
  encPrivateKey: normalizePem(spData?.encPrivateKey),
1539
1613
  encPrivateKeyPass: spData?.encPrivateKeyPass,
1540
1614
  nameIDFormat: config.identifierFormat ? [config.identifierFormat] : void 0,
1541
- relayState: opts?.relayState
1615
+ relayState: opts?.relayState,
1616
+ clockDrifts: opts?.clockSkew && opts?.clockSkew !== 0 ? [-opts.clockSkew, opts.clockSkew] : void 0
1542
1617
  });
1543
1618
  }
1544
1619
  function createIdP(config) {
@@ -1694,7 +1769,7 @@ async function processSAMLResponse(ctx, params, options) {
1694
1769
  if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
1695
1770
  const parsedSamlConfig = typeof provider.samlConfig === "object" ? provider.samlConfig : safeJsonParse(provider.samlConfig);
1696
1771
  if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
1697
- const sp = createSP(parsedSamlConfig, ctx.context.baseURL, providerId);
1772
+ const sp = createSP(parsedSamlConfig, ctx.context.baseURL, providerId, { clockSkew: options?.saml?.clockSkew });
1698
1773
  const idp = createIdP(parsedSamlConfig);
1699
1774
  const samlRedirectUrl = getSafeRedirectUrl(relayState?.callbackURL || parsedSamlConfig.callbackUrl, params.currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
1700
1775
  validateSingleAssertion(SAMLResponse);
@@ -1726,11 +1801,10 @@ async function processSAMLResponse(ctx, params, options) {
1726
1801
  if (options?.saml?.enableInResponseToValidation !== false) {
1727
1802
  const allowIdpInitiated = options?.saml?.allowIdpInitiated !== false;
1728
1803
  if (inResponseTo) {
1804
+ const consumed = await ctx.context.internalAdapter.consumeVerificationValue(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
1729
1805
  let storedRequest = null;
1730
- const verification = await ctx.context.internalAdapter.findVerificationValue(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
1731
- if (verification) try {
1732
- storedRequest = JSON.parse(verification.value);
1733
- if (storedRequest && storedRequest.expiresAt < Date.now()) storedRequest = null;
1806
+ if (consumed) try {
1807
+ storedRequest = JSON.parse(consumed.value);
1734
1808
  } catch {
1735
1809
  storedRequest = null;
1736
1810
  }
@@ -1747,10 +1821,8 @@ async function processSAMLResponse(ctx, params, options) {
1747
1821
  expectedProvider: storedRequest.providerId,
1748
1822
  actualProvider: providerId
1749
1823
  });
1750
- await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
1751
1824
  throw ctx.redirect(`${samlRedirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`);
1752
1825
  }
1753
- await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
1754
1826
  } else if (!allowIdpInitiated) {
1755
1827
  ctx.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId });
1756
1828
  throw ctx.redirect(`${samlRedirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
@@ -1815,7 +1887,7 @@ async function processSAMLResponse(ctx, params, options) {
1815
1887
  });
1816
1888
  throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
1817
1889
  }
1818
- 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);
1819
1891
  const callbackUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1820
1892
  const errorUrl = relayState?.errorURL || samlRedirectUrl;
1821
1893
  let result;
@@ -1835,7 +1907,8 @@ async function processSAMLResponse(ctx, params, options) {
1835
1907
  },
1836
1908
  callbackURL: callbackUrl,
1837
1909
  disableSignUp: options?.disableImplicitSignUp,
1838
- isTrustedProvider
1910
+ isTrustedProvider,
1911
+ trustProviderByName: false
1839
1912
  });
1840
1913
  } catch (e) {
1841
1914
  if (isAPIError(e) && e.body?.code) {
@@ -2226,6 +2299,14 @@ const registerSSOProvider = (options) => {
2226
2299
  if (!member) throw new APIError("BAD_REQUEST", { message: "You are not a member of the organization" });
2227
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" });
2228
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
+ }
2229
2310
  if (await ctx.context.adapter.findOne({
2230
2311
  model: "ssoProvider",
2231
2312
  where: [{
@@ -2686,7 +2767,10 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
2686
2767
  let userInfo = null;
2687
2768
  const mapping = config.mapping || {};
2688
2769
  if (config.userInfoEndpoint) {
2689
- 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
+ });
2690
2774
  if (userInfoResponse.error) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=${userInfoResponse.error.message}`);
2691
2775
  const rawUserInfo = userInfoResponse.data;
2692
2776
  userInfo = {
@@ -2742,7 +2826,8 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
2742
2826
  callbackURL,
2743
2827
  disableSignUp: options?.disableImplicitSignUp && !requestSignUp,
2744
2828
  overrideUserInfo: config.overrideUserInfo,
2745
- isTrustedProvider
2829
+ isTrustedProvider,
2830
+ trustProviderByName: false
2746
2831
  });
2747
2832
  } catch (e) {
2748
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.14";
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.14",
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.14",
74
- "better-auth": "1.6.14"
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.14",
81
- "better-auth": "^1.6.14"
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",