@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
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
|
|
@@ -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
|
-
|
|
1731
|
-
|
|
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 =
|
|
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, {
|
|
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) {
|
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",
|