@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
package/dist/index.mjs
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { t as PACKAGE_VERSION } from "./version-
|
|
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
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
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
|
-
|
|
1732
|
-
|
|
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 =
|
|
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, {
|
|
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) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@better-auth/sso",
|
|
3
|
-
"version": "1.6.
|
|
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.
|
|
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.
|
|
74
|
-
"better-auth": "1.6.
|
|
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.
|
|
79
|
-
"better-call": "1.3.
|
|
80
|
-
"@better-auth/core": "^1.6.
|
|
81
|
-
"better-auth": "^1.6.
|
|
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",
|