@better-auth/sso 1.5.0-beta.1 → 1.5.0-beta.3

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.
@@ -1,5 +1,5 @@
1
1
 
2
- > @better-auth/sso@1.5.0-beta.1 build /home/runner/work/better-auth/better-auth/packages/sso
2
+ > @better-auth/sso@1.5.0-beta.3 build /home/runner/work/better-auth/better-auth/packages/sso
3
3
  > tsdown
4
4
 
5
5
  ℹ tsdown v0.17.2 powered by rolldown v1.0.0-beta.53
@@ -7,10 +7,10 @@
7
7
  ℹ entry: src/index.ts, src/client.ts
8
8
  ℹ tsconfig: tsconfig.json
9
9
  ℹ Build start
10
- ℹ dist/index.mjs 95.91 kB │ gzip: 18.60 kB
10
+ ℹ dist/index.mjs 99.53 kB │ gzip: 19.50 kB
11
11
  ℹ dist/client.mjs  0.15 kB │ gzip: 0.14 kB
12
- ℹ dist/index.d.mts  1.48 kB │ gzip: 0.51 kB
13
- ℹ dist/client.d.mts  0.49 kB │ gzip: 0.29 kB
14
- ℹ dist/index-CvpS40sl.d.mts 43.12 kB │ gzip: 8.83 kB
15
- ℹ 5 files, total: 141.14 kB
16
- ✔ Build complete in 15982ms
12
+ ℹ dist/index.d.mts  1.67 kB │ gzip: 0.57 kB
13
+ ℹ dist/client.d.mts  0.49 kB │ gzip: 0.30 kB
14
+ ℹ dist/index-BLMoKtp1.d.mts 44.35 kB │ gzip: 9.16 kB
15
+ ℹ 5 files, total: 146.19 kB
16
+ ✔ Build complete in 17127ms
package/LICENSE.md CHANGED
@@ -1,17 +1,20 @@
1
1
  The MIT License (MIT)
2
2
  Copyright (c) 2024 - present, Bereket Engida
3
3
 
4
- Permission is hereby granted, free of charge, to any person obtaining a copy of this software
5
- and associated documentation files (the "Software"), to deal in the Software without restriction,
6
- including without limitation the rights to use, copy, modify, merge, publish, distribute,
7
- sublicense, and/or sell copies of the Software, and to permit persons to whom the Software
8
- is furnished to do so, subject to the following conditions:
4
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
5
+ this software and associated documentation files (the Software), to deal in
6
+ the Software without restriction, including without limitation the rights to
7
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
8
+ the Software, and to permit persons to whom the Software is furnished to do so,
9
+ subject to the following conditions:
9
10
 
10
- The above copyright notice and this permission notice shall be included in all copies or
11
- substantial portions of the Software.
11
+ The above copyright notice and this permission notice shall be included in all
12
+ copies or substantial portions of the Software.
12
13
 
13
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
14
- BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
15
- NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
16
- DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
17
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
14
+ THE SOFTWARE IS PROVIDED AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
16
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
17
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
18
+ DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
19
+ ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
20
+ DEALINGS IN THE SOFTWARE.
package/dist/client.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { t as SSOPlugin } from "./index-CvpS40sl.mjs";
1
+ import { t as SSOPlugin } from "./index-BLMoKtp1.mjs";
2
2
 
3
3
  //#region src/client.d.ts
4
4
  interface SSOClientOptions {
@@ -367,6 +367,18 @@ interface SSOOptions {
367
367
  * ```
368
368
  */
369
369
  algorithms?: AlgorithmValidationOptions;
370
+ /**
371
+ * Maximum allowed size for SAML responses in bytes.
372
+ *
373
+ * @default 262144 (256KB)
374
+ */
375
+ maxResponseSize?: number;
376
+ /**
377
+ * Maximum allowed size for IdP metadata XML in bytes.
378
+ *
379
+ * @default 102400 (100KB)
380
+ */
381
+ maxMetadataSize?: number;
370
382
  };
371
383
  }
372
384
  //#endregion
@@ -956,6 +968,28 @@ declare const acsEndpoint: (options?: SSOOptions) => better_call0.StrictEndpoint
956
968
  };
957
969
  }, never>;
958
970
  //#endregion
971
+ //#region src/constants.d.ts
972
+ /**
973
+ * Default clock skew tolerance (5 minutes).
974
+ * Allows for minor time differences between IdP and SP servers.
975
+ *
976
+ * Accommodates:
977
+ * - Network latency and processing time
978
+ * - Clock synchronization differences (NTP drift)
979
+ * - Distributed systems across timezones
980
+ */
981
+ declare const DEFAULT_CLOCK_SKEW_MS: number;
982
+ /**
983
+ * Default maximum size for SAML responses (256 KB).
984
+ * Protects against memory exhaustion from oversized SAML payloads.
985
+ */
986
+ declare const DEFAULT_MAX_SAML_RESPONSE_SIZE: number;
987
+ /**
988
+ * Default maximum size for IdP metadata (100 KB).
989
+ * Protects against oversized metadata documents.
990
+ */
991
+ declare const DEFAULT_MAX_SAML_METADATA_SIZE: number;
992
+ //#endregion
959
993
  //#region src/oidc/types.d.ts
960
994
  /**
961
995
  * OIDC Discovery Types
@@ -1215,6 +1249,13 @@ declare function selectTokenEndpointAuthMethod(doc: OIDCDiscoveryDocument, exist
1215
1249
  declare function needsRuntimeDiscovery(config: Partial<HydratedOIDCConfig> | undefined): boolean;
1216
1250
  //#endregion
1217
1251
  //#region src/index.d.ts
1252
+ declare module "@better-auth/core" {
1253
+ interface BetterAuthPluginRegistry<Auth, Context> {
1254
+ sso: {
1255
+ creator: typeof sso;
1256
+ };
1257
+ }
1258
+ }
1218
1259
  type DomainVerificationEndpoints = {
1219
1260
  requestDomainVerification: ReturnType<typeof requestDomainVerification>;
1220
1261
  verifyDomain: ReturnType<typeof verifyDomain>;
@@ -1250,4 +1291,4 @@ declare function sso<O extends SSOOptions>(options?: O | undefined): {
1250
1291
  endpoints: SSOEndpoints<O>;
1251
1292
  };
1252
1293
  //#endregion
1253
- export { KeyEncryptionAlgorithm as A, SAMLConfig as C, DataEncryptionAlgorithm as D, AlgorithmValidationOptions as E, DeprecatedAlgorithmBehavior as O, OIDCConfig as S, SSOProvider as T, REQUIRED_DISCOVERY_FIELDS as _, fetchDiscoveryDocument as a, TimestampValidationOptions as b, normalizeUrl as c, validateDiscoveryUrl as d, DiscoverOIDCConfigParams as f, OIDCDiscoveryDocument as g, HydratedOIDCConfig as h, discoverOIDCConfig as i, SignatureAlgorithm as j, DigestAlgorithm as k, selectTokenEndpointAuthMethod as l, DiscoveryErrorCode as m, sso as n, needsRuntimeDiscovery as o, DiscoveryError as p, computeDiscoveryUrl as r, normalizeDiscoveryUrls as s, SSOPlugin as t, validateDiscoveryDocument as u, RequiredDiscoveryField as v, SSOOptions as w, validateSAMLTimestamp as x, SAMLConditions as y };
1294
+ export { DataEncryptionAlgorithm as A, TimestampValidationOptions as C, SSOOptions as D, SAMLConfig as E, DigestAlgorithm as M, KeyEncryptionAlgorithm as N, SSOProvider as O, SignatureAlgorithm as P, SAMLConditions as S, OIDCConfig as T, REQUIRED_DISCOVERY_FIELDS as _, fetchDiscoveryDocument as a, DEFAULT_MAX_SAML_METADATA_SIZE as b, normalizeUrl as c, validateDiscoveryUrl as d, DiscoverOIDCConfigParams as f, OIDCDiscoveryDocument as g, HydratedOIDCConfig as h, discoverOIDCConfig as i, DeprecatedAlgorithmBehavior as j, AlgorithmValidationOptions as k, selectTokenEndpointAuthMethod as l, DiscoveryErrorCode as m, sso as n, needsRuntimeDiscovery as o, DiscoveryError as p, computeDiscoveryUrl as r, normalizeDiscoveryUrls as s, SSOPlugin as t, validateDiscoveryDocument as u, RequiredDiscoveryField as v, validateSAMLTimestamp as w, DEFAULT_MAX_SAML_RESPONSE_SIZE as x, DEFAULT_CLOCK_SKEW_MS as y };
package/dist/index.d.mts CHANGED
@@ -1,2 +1,2 @@
1
- import { A as KeyEncryptionAlgorithm, C as SAMLConfig, D as DataEncryptionAlgorithm, E as AlgorithmValidationOptions, O as DeprecatedAlgorithmBehavior, S as OIDCConfig, T as SSOProvider, _ as REQUIRED_DISCOVERY_FIELDS, a as fetchDiscoveryDocument, b as TimestampValidationOptions, c as normalizeUrl, d as validateDiscoveryUrl, f as DiscoverOIDCConfigParams, g as OIDCDiscoveryDocument, h as HydratedOIDCConfig, i as discoverOIDCConfig, j as SignatureAlgorithm, k as DigestAlgorithm, l as selectTokenEndpointAuthMethod, m as DiscoveryErrorCode, n as sso, o as needsRuntimeDiscovery, p as DiscoveryError, r as computeDiscoveryUrl, s as normalizeDiscoveryUrls, t as SSOPlugin, u as validateDiscoveryDocument, v as RequiredDiscoveryField, w as SSOOptions, x as validateSAMLTimestamp, y as SAMLConditions } from "./index-CvpS40sl.mjs";
2
- export { AlgorithmValidationOptions, DataEncryptionAlgorithm, DeprecatedAlgorithmBehavior, DigestAlgorithm, DiscoverOIDCConfigParams, DiscoveryError, DiscoveryErrorCode, HydratedOIDCConfig, KeyEncryptionAlgorithm, OIDCConfig, OIDCDiscoveryDocument, REQUIRED_DISCOVERY_FIELDS, RequiredDiscoveryField, SAMLConditions, SAMLConfig, SSOOptions, SSOPlugin, SSOProvider, SignatureAlgorithm, TimestampValidationOptions, computeDiscoveryUrl, discoverOIDCConfig, fetchDiscoveryDocument, needsRuntimeDiscovery, normalizeDiscoveryUrls, normalizeUrl, selectTokenEndpointAuthMethod, sso, validateDiscoveryDocument, validateDiscoveryUrl, validateSAMLTimestamp };
1
+ import { A as DataEncryptionAlgorithm, C as TimestampValidationOptions, D as SSOOptions, E as SAMLConfig, M as DigestAlgorithm, N as KeyEncryptionAlgorithm, O as SSOProvider, P as SignatureAlgorithm, S as SAMLConditions, T as OIDCConfig, _ as REQUIRED_DISCOVERY_FIELDS, a as fetchDiscoveryDocument, b as DEFAULT_MAX_SAML_METADATA_SIZE, c as normalizeUrl, d as validateDiscoveryUrl, f as DiscoverOIDCConfigParams, g as OIDCDiscoveryDocument, h as HydratedOIDCConfig, i as discoverOIDCConfig, j as DeprecatedAlgorithmBehavior, k as AlgorithmValidationOptions, l as selectTokenEndpointAuthMethod, m as DiscoveryErrorCode, n as sso, o as needsRuntimeDiscovery, p as DiscoveryError, r as computeDiscoveryUrl, s as normalizeDiscoveryUrls, t as SSOPlugin, u as validateDiscoveryDocument, v as RequiredDiscoveryField, w as validateSAMLTimestamp, x as DEFAULT_MAX_SAML_RESPONSE_SIZE, y as DEFAULT_CLOCK_SKEW_MS } from "./index-BLMoKtp1.mjs";
2
+ export { AlgorithmValidationOptions, DEFAULT_CLOCK_SKEW_MS, DEFAULT_MAX_SAML_METADATA_SIZE, DEFAULT_MAX_SAML_RESPONSE_SIZE, DataEncryptionAlgorithm, DeprecatedAlgorithmBehavior, DigestAlgorithm, DiscoverOIDCConfigParams, DiscoveryError, DiscoveryErrorCode, HydratedOIDCConfig, KeyEncryptionAlgorithm, OIDCConfig, OIDCDiscoveryDocument, REQUIRED_DISCOVERY_FIELDS, RequiredDiscoveryField, SAMLConditions, SAMLConfig, SSOOptions, SSOPlugin, SSOProvider, SignatureAlgorithm, TimestampValidationOptions, computeDiscoveryUrl, discoverOIDCConfig, fetchDiscoveryDocument, needsRuntimeDiscovery, normalizeDiscoveryUrls, normalizeUrl, selectTokenEndpointAuthMethod, sso, validateDiscoveryDocument, validateDiscoveryUrl, validateSAMLTimestamp };
package/dist/index.mjs CHANGED
@@ -4,12 +4,12 @@ import * as saml from "samlify";
4
4
  import { generateRandomString } from "better-auth/crypto";
5
5
  import * as z$1 from "zod/v4";
6
6
  import z from "zod/v4";
7
- import { base64 } from "@better-auth/utils/base64";
8
7
  import { BetterFetchError, betterFetch } from "@better-fetch/fetch";
9
8
  import { HIDE_METADATA, createAuthorizationURL, generateState, parseState, validateAuthorizationCode, validateToken } from "better-auth";
10
9
  import { setSessionCookie } from "better-auth/cookies";
11
10
  import { handleOAuthUserInfo } from "better-auth/oauth2";
12
11
  import { decodeJwt } from "jose";
12
+ import { base64 } from "@better-auth/utils/base64";
13
13
 
14
14
  //#region src/linking/org-assignment.ts
15
15
  /**
@@ -306,6 +306,16 @@ const DEFAULT_ASSERTION_TTL_MS = 900 * 1e3;
306
306
  * - Distributed systems across timezones
307
307
  */
308
308
  const DEFAULT_CLOCK_SKEW_MS = 300 * 1e3;
309
+ /**
310
+ * Default maximum size for SAML responses (256 KB).
311
+ * Protects against memory exhaustion from oversized SAML payloads.
312
+ */
313
+ const DEFAULT_MAX_SAML_RESPONSE_SIZE = 256 * 1024;
314
+ /**
315
+ * Default maximum size for IdP metadata (100 KB).
316
+ * Protects against oversized metadata documents.
317
+ */
318
+ const DEFAULT_MAX_SAML_METADATA_SIZE = 100 * 1024;
309
319
 
310
320
  //#endregion
311
321
  //#region src/oidc/types.ts
@@ -653,6 +663,41 @@ function mapDiscoveryErrorToAPIError(error) {
653
663
  }
654
664
  }
655
665
 
666
+ //#endregion
667
+ //#region src/saml/parser.ts
668
+ const xmlParser = new XMLParser({
669
+ ignoreAttributes: false,
670
+ attributeNamePrefix: "@_",
671
+ removeNSPrefix: true,
672
+ processEntities: false
673
+ });
674
+ function findNode(obj, nodeName) {
675
+ if (!obj || typeof obj !== "object") return null;
676
+ const record = obj;
677
+ if (nodeName in record) return record[nodeName];
678
+ for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) {
679
+ const found = findNode(item, nodeName);
680
+ if (found) return found;
681
+ }
682
+ else if (typeof value === "object" && value !== null) {
683
+ const found = findNode(value, nodeName);
684
+ if (found) return found;
685
+ }
686
+ return null;
687
+ }
688
+ function countAllNodes(obj, nodeName) {
689
+ if (!obj || typeof obj !== "object") return 0;
690
+ let count = 0;
691
+ const record = obj;
692
+ if (nodeName in record) {
693
+ const node = record[nodeName];
694
+ count += Array.isArray(node) ? node.length : 1;
695
+ }
696
+ for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) count += countAllNodes(item, nodeName);
697
+ else if (typeof value === "object" && value !== null) count += countAllNodes(value, nodeName);
698
+ return count;
699
+ }
700
+
656
701
  //#endregion
657
702
  //#region src/saml/algorithms.ts
658
703
  const SignatureAlgorithm = {
@@ -726,25 +771,6 @@ function normalizeSignatureAlgorithm(alg) {
726
771
  function normalizeDigestAlgorithm(alg) {
727
772
  return SHORT_FORM_DIGEST_TO_URI[alg.toLowerCase()] ?? alg;
728
773
  }
729
- const xmlParser = new XMLParser({
730
- ignoreAttributes: false,
731
- attributeNamePrefix: "@_",
732
- removeNSPrefix: true
733
- });
734
- function findNode(obj, nodeName) {
735
- if (!obj || typeof obj !== "object") return null;
736
- const record = obj;
737
- if (nodeName in record) return record[nodeName];
738
- for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) {
739
- const found = findNode(item, nodeName);
740
- if (found) return found;
741
- }
742
- else if (typeof value === "object" && value !== null) {
743
- const found = findNode(value, nodeName);
744
- if (found) return found;
745
- }
746
- return null;
747
- }
748
774
  function extractEncryptionAlgorithms(xml) {
749
775
  try {
750
776
  const parsed = xmlParser.parse(xml);
@@ -853,6 +879,49 @@ function validateConfigAlgorithms(config, options = {}) {
853
879
  }
854
880
  }
855
881
 
882
+ //#endregion
883
+ //#region src/saml/assertions.ts
884
+ /** @lintignore used in tests */
885
+ function countAssertions(xml) {
886
+ let parsed;
887
+ try {
888
+ parsed = xmlParser.parse(xml);
889
+ } catch {
890
+ throw new APIError("BAD_REQUEST", {
891
+ message: "Failed to parse SAML response XML",
892
+ code: "SAML_INVALID_XML"
893
+ });
894
+ }
895
+ const assertions = countAllNodes(parsed, "Assertion");
896
+ const encryptedAssertions = countAllNodes(parsed, "EncryptedAssertion");
897
+ return {
898
+ assertions,
899
+ encryptedAssertions,
900
+ total: assertions + encryptedAssertions
901
+ };
902
+ }
903
+ function validateSingleAssertion(samlResponse) {
904
+ let xml;
905
+ try {
906
+ xml = new TextDecoder().decode(base64.decode(samlResponse));
907
+ if (!xml.includes("<")) throw new Error("Not XML");
908
+ } catch {
909
+ throw new APIError("BAD_REQUEST", {
910
+ message: "Invalid base64-encoded SAML response",
911
+ code: "SAML_INVALID_ENCODING"
912
+ });
913
+ }
914
+ const counts = countAssertions(xml);
915
+ if (counts.total === 0) throw new APIError("BAD_REQUEST", {
916
+ message: "SAML response contains no assertions",
917
+ code: "SAML_NO_ASSERTION"
918
+ });
919
+ if (counts.total > 1) throw new APIError("BAD_REQUEST", {
920
+ message: `SAML response contains ${counts.total} assertions, expected exactly 1`,
921
+ code: "SAML_MULTIPLE_ASSERTIONS"
922
+ });
923
+ }
924
+
856
925
  //#endregion
857
926
  //#region src/utils.ts
858
927
  /**
@@ -1239,6 +1308,10 @@ const registerSSOProvider = (options) => {
1239
1308
  })).length >= limit) throw new APIError("FORBIDDEN", { message: "You have reached the maximum number of SSO providers" });
1240
1309
  const body = ctx.body;
1241
1310
  if (z.string().url().safeParse(body.issuer).error) throw new APIError("BAD_REQUEST", { message: "Invalid issuer. Must be a valid URL" });
1311
+ if (body.samlConfig?.idpMetadata?.metadata) {
1312
+ const maxMetadataSize = options?.saml?.maxMetadataSize ?? DEFAULT_MAX_SAML_METADATA_SIZE;
1313
+ if (new TextEncoder().encode(body.samlConfig.idpMetadata.metadata).length > maxMetadataSize) throw new APIError("BAD_REQUEST", { message: `IdP metadata exceeds maximum allowed size (${maxMetadataSize} bytes)` });
1314
+ }
1242
1315
  if (ctx.body.organizationId) {
1243
1316
  if (!await ctx.context.adapter.findOne({
1244
1317
  model: "member",
@@ -1273,7 +1346,7 @@ const registerSSOProvider = (options) => {
1273
1346
  userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
1274
1347
  tokenEndpointAuthentication: body.oidcConfig.tokenEndpointAuthentication
1275
1348
  },
1276
- isTrustedOrigin: ctx.context.isTrustedOrigin
1349
+ isTrustedOrigin: (url) => ctx.context.isTrustedOrigin(url)
1277
1350
  });
1278
1351
  } catch (error) {
1279
1352
  if (error instanceof DiscoveryError) throw mapDiscoveryErrorToAPIError(error);
@@ -1773,6 +1846,8 @@ const callbackSSOSAML = (options) => {
1773
1846
  }, async (ctx) => {
1774
1847
  const { SAMLResponse, RelayState } = ctx.body;
1775
1848
  const { providerId } = ctx.params;
1849
+ const maxResponseSize = options?.saml?.maxResponseSize ?? DEFAULT_MAX_SAML_RESPONSE_SIZE;
1850
+ if (new TextEncoder().encode(SAMLResponse).length > maxResponseSize) throw new APIError("BAD_REQUEST", { message: `SAML response exceeds maximum allowed size (${maxResponseSize} bytes)` });
1776
1851
  let provider = null;
1777
1852
  if (options?.defaultSSO?.length) {
1778
1853
  const matchingDefault = options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === providerId);
@@ -1838,6 +1913,7 @@ const callbackSSOSAML = (options) => {
1838
1913
  wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
1839
1914
  nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
1840
1915
  });
1916
+ validateSingleAssertion(SAMLResponse);
1841
1917
  let parsedResponse;
1842
1918
  try {
1843
1919
  parsedResponse = await sp.parseLoginResponse(idp, "post", { body: {
@@ -1848,7 +1924,7 @@ const callbackSSOSAML = (options) => {
1848
1924
  } catch (error) {
1849
1925
  ctx.context.logger.error("SAML response validation failed", {
1850
1926
  error,
1851
- decodedResponse: new TextDecoder().decode(base64.decode(SAMLResponse))
1927
+ decodedResponse: Buffer.from(SAMLResponse, "base64").toString("utf-8")
1852
1928
  });
1853
1929
  throw new APIError("BAD_REQUEST", {
1854
1930
  message: "Invalid SAML response",
@@ -2024,6 +2100,8 @@ const acsEndpoint = (options) => {
2024
2100
  }, async (ctx) => {
2025
2101
  const { SAMLResponse, RelayState = "" } = ctx.body;
2026
2102
  const { providerId } = ctx.params;
2103
+ const maxResponseSize = options?.saml?.maxResponseSize ?? DEFAULT_MAX_SAML_RESPONSE_SIZE;
2104
+ if (new TextEncoder().encode(SAMLResponse).length > maxResponseSize) throw new APIError("BAD_REQUEST", { message: `SAML response exceeds maximum allowed size (${maxResponseSize} bytes)` });
2027
2105
  let provider = null;
2028
2106
  if (options?.defaultSSO?.length) {
2029
2107
  const matchingDefault = providerId ? options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === providerId) : options.defaultSSO[0];
@@ -2072,6 +2150,16 @@ const acsEndpoint = (options) => {
2072
2150
  }],
2073
2151
  signingCert: idpData?.cert || parsedSamlConfig.cert
2074
2152
  }) : saml.IdentityProvider({ metadata: idpData.metadata });
2153
+ try {
2154
+ validateSingleAssertion(SAMLResponse);
2155
+ } catch (error) {
2156
+ if (error instanceof APIError) {
2157
+ const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2158
+ const errorCode = error.body?.code === "SAML_MULTIPLE_ASSERTIONS" ? "multiple_assertions" : "no_assertion";
2159
+ throw ctx.redirect(`${redirectUrl}?error=${errorCode}&error_description=${encodeURIComponent(error.message)}`);
2160
+ }
2161
+ throw error;
2162
+ }
2075
2163
  let parsedResponse;
2076
2164
  try {
2077
2165
  parsedResponse = await sp.parseLoginResponse(idp, "post", { body: {
@@ -2082,7 +2170,7 @@ const acsEndpoint = (options) => {
2082
2170
  } catch (error) {
2083
2171
  ctx.context.logger.error("SAML response validation failed", {
2084
2172
  error,
2085
- decodedResponse: new TextDecoder().decode(base64.decode(SAMLResponse))
2173
+ decodedResponse: Buffer.from(SAMLResponse, "base64").toString("utf-8")
2086
2174
  });
2087
2175
  throw new APIError("BAD_REQUEST", {
2088
2176
  message: "Invalid SAML response",
@@ -2133,7 +2221,7 @@ const acsEndpoint = (options) => {
2133
2221
  throw ctx.redirect(`${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
2134
2222
  }
2135
2223
  }
2136
- const assertionIdAcs = extractAssertionId(new TextDecoder().decode(base64.decode(SAMLResponse)));
2224
+ const assertionIdAcs = extractAssertionId(Buffer.from(SAMLResponse, "base64").toString("utf-8"));
2137
2225
  if (assertionIdAcs) {
2138
2226
  const issuer = idp.entityMeta.getEntityID();
2139
2227
  const conditions = extract.conditions;
@@ -2332,4 +2420,4 @@ function sso(options) {
2332
2420
  }
2333
2421
 
2334
2422
  //#endregion
2335
- export { DataEncryptionAlgorithm, DigestAlgorithm, DiscoveryError, KeyEncryptionAlgorithm, REQUIRED_DISCOVERY_FIELDS, SignatureAlgorithm, computeDiscoveryUrl, discoverOIDCConfig, fetchDiscoveryDocument, needsRuntimeDiscovery, normalizeDiscoveryUrls, normalizeUrl, selectTokenEndpointAuthMethod, sso, validateDiscoveryDocument, validateDiscoveryUrl, validateSAMLTimestamp };
2423
+ export { DEFAULT_CLOCK_SKEW_MS, DEFAULT_MAX_SAML_METADATA_SIZE, DEFAULT_MAX_SAML_RESPONSE_SIZE, DataEncryptionAlgorithm, DigestAlgorithm, DiscoveryError, KeyEncryptionAlgorithm, REQUIRED_DISCOVERY_FIELDS, SignatureAlgorithm, computeDiscoveryUrl, discoverOIDCConfig, fetchDiscoveryDocument, needsRuntimeDiscovery, normalizeDiscoveryUrls, normalizeUrl, selectTokenEndpointAuthMethod, sso, validateDiscoveryDocument, validateDiscoveryUrl, validateSAMLTimestamp };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@better-auth/sso",
3
3
  "author": "Bereket Engida",
4
- "version": "1.5.0-beta.1",
4
+ "version": "1.5.0-beta.3",
5
5
  "type": "module",
6
6
  "main": "dist/index.mjs",
7
7
  "types": "dist/index.d.mts",
@@ -62,15 +62,18 @@
62
62
  "devDependencies": {
63
63
  "@types/body-parser": "^1.19.6",
64
64
  "@types/express": "^5.0.5",
65
- "better-call": "1.1.7",
65
+ "better-call": "1.1.8",
66
66
  "body-parser": "^2.2.1",
67
67
  "express": "^5.1.0",
68
68
  "oauth2-mock-server": "^8.2.0",
69
69
  "tsdown": "^0.17.2",
70
- "better-auth": "1.5.0-beta.1"
70
+ "@better-auth/core": "1.5.0-beta.3",
71
+ "better-auth": "1.5.0-beta.3"
71
72
  },
72
73
  "peerDependencies": {
73
- "better-auth": "1.5.0-beta.1"
74
+ "@better-auth/utils": "0.3.0",
75
+ "@better-auth/core": "1.5.0-beta.3",
76
+ "better-auth": "1.5.0-beta.3"
74
77
  },
75
78
  "scripts": {
76
79
  "test": "vitest",
package/src/constants.ts CHANGED
@@ -40,3 +40,19 @@ export const DEFAULT_ASSERTION_TTL_MS = 15 * 60 * 1000;
40
40
  * - Distributed systems across timezones
41
41
  */
42
42
  export const DEFAULT_CLOCK_SKEW_MS = 5 * 60 * 1000;
43
+
44
+ // ============================================================================
45
+ // Size Limits (DoS Protection)
46
+ // ============================================================================
47
+
48
+ /**
49
+ * Default maximum size for SAML responses (256 KB).
50
+ * Protects against memory exhaustion from oversized SAML payloads.
51
+ */
52
+ export const DEFAULT_MAX_SAML_RESPONSE_SIZE = 256 * 1024;
53
+
54
+ /**
55
+ * Default maximum size for IdP metadata (100 KB).
56
+ * Protects against oversized metadata documents.
57
+ */
58
+ export const DEFAULT_MAX_SAML_METADATA_SIZE = 100 * 1024;
package/src/index.ts CHANGED
@@ -16,6 +16,12 @@ import {
16
16
  spMetadata,
17
17
  } from "./routes/sso";
18
18
 
19
+ export {
20
+ DEFAULT_CLOCK_SKEW_MS,
21
+ DEFAULT_MAX_SAML_METADATA_SIZE,
22
+ DEFAULT_MAX_SAML_RESPONSE_SIZE,
23
+ } from "./constants";
24
+
19
25
  export {
20
26
  type SAMLConditions,
21
27
  type TimestampValidationOptions,
@@ -35,6 +41,15 @@ import type { OIDCConfig, SAMLConfig, SSOOptions, SSOProvider } from "./types";
35
41
 
36
42
  export type { SAMLConfig, OIDCConfig, SSOOptions, SSOProvider };
37
43
 
44
+ declare module "@better-auth/core" {
45
+ // biome-ignore lint/correctness/noUnusedVariables: Auth and Context need to be same as declared in the module
46
+ interface BetterAuthPluginRegistry<Auth, Context> {
47
+ sso: {
48
+ creator: typeof sso;
49
+ };
50
+ }
51
+ }
52
+
38
53
  export {
39
54
  computeDiscoveryUrl,
40
55
  type DiscoverOIDCConfigParams,
package/src/routes/sso.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { base64 } from "@better-auth/utils/base64";
2
1
  import { BetterFetchError, betterFetch } from "@better-fetch/fetch";
3
2
  import type { User, Verification } from "better-auth";
4
3
  import {
@@ -37,6 +36,8 @@ import {
37
36
  DEFAULT_ASSERTION_TTL_MS,
38
37
  DEFAULT_AUTHN_REQUEST_TTL_MS,
39
38
  DEFAULT_CLOCK_SKEW_MS,
39
+ DEFAULT_MAX_SAML_METADATA_SIZE,
40
+ DEFAULT_MAX_SAML_RESPONSE_SIZE,
40
41
  USED_ASSERTION_KEY_PREFIX,
41
42
  } from "../constants";
42
43
  import { assignOrganizationFromProvider } from "../linking";
@@ -46,7 +47,11 @@ import {
46
47
  discoverOIDCConfig,
47
48
  mapDiscoveryErrorToAPIError,
48
49
  } from "../oidc";
49
- import { validateConfigAlgorithms, validateSAMLAlgorithms } from "../saml";
50
+ import {
51
+ validateConfigAlgorithms,
52
+ validateSAMLAlgorithms,
53
+ validateSingleAssertion,
54
+ } from "../saml";
50
55
  import type { OIDCConfig, SAMLConfig, SSOOptions, SSOProvider } from "../types";
51
56
  import { safeJsonParse, validateEmailDomain } from "../utils";
52
57
 
@@ -666,6 +671,20 @@ export const registerSSOProvider = <O extends SSOOptions>(options: O) => {
666
671
  message: "Invalid issuer. Must be a valid URL",
667
672
  });
668
673
  }
674
+
675
+ if (body.samlConfig?.idpMetadata?.metadata) {
676
+ const maxMetadataSize =
677
+ options?.saml?.maxMetadataSize ?? DEFAULT_MAX_SAML_METADATA_SIZE;
678
+ if (
679
+ new TextEncoder().encode(body.samlConfig.idpMetadata.metadata)
680
+ .length > maxMetadataSize
681
+ ) {
682
+ throw new APIError("BAD_REQUEST", {
683
+ message: `IdP metadata exceeds maximum allowed size (${maxMetadataSize} bytes)`,
684
+ });
685
+ }
686
+ }
687
+
669
688
  if (ctx.body.organizationId) {
670
689
  const organization = await ctx.context.adapter.findOne({
671
690
  model: "member",
@@ -720,7 +739,7 @@ export const registerSSOProvider = <O extends SSOOptions>(options: O) => {
720
739
  tokenEndpointAuthentication:
721
740
  body.oidcConfig.tokenEndpointAuthentication,
722
741
  },
723
- isTrustedOrigin: ctx.context.isTrustedOrigin,
742
+ isTrustedOrigin: (url: string) => ctx.context.isTrustedOrigin(url),
724
743
  });
725
744
  } catch (error) {
726
745
  if (error instanceof DiscoveryError) {
@@ -1698,6 +1717,15 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
1698
1717
  async (ctx) => {
1699
1718
  const { SAMLResponse, RelayState } = ctx.body;
1700
1719
  const { providerId } = ctx.params;
1720
+
1721
+ const maxResponseSize =
1722
+ options?.saml?.maxResponseSize ?? DEFAULT_MAX_SAML_RESPONSE_SIZE;
1723
+ if (new TextEncoder().encode(SAMLResponse).length > maxResponseSize) {
1724
+ throw new APIError("BAD_REQUEST", {
1725
+ message: `SAML response exceeds maximum allowed size (${maxResponseSize} bytes)`,
1726
+ });
1727
+ }
1728
+
1701
1729
  let provider: SSOProvider<SSOOptions> | null = null;
1702
1730
  if (options?.defaultSSO?.length) {
1703
1731
  const matchingDefault = options.defaultSSO.find(
@@ -1811,6 +1839,8 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
1811
1839
  : undefined,
1812
1840
  });
1813
1841
 
1842
+ validateSingleAssertion(SAMLResponse);
1843
+
1814
1844
  let parsedResponse: FlowResult;
1815
1845
  try {
1816
1846
  parsedResponse = await sp.parseLoginResponse(idp, "post", {
@@ -1826,8 +1856,8 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
1826
1856
  } catch (error) {
1827
1857
  ctx.context.logger.error("SAML response validation failed", {
1828
1858
  error,
1829
- decodedResponse: new TextDecoder().decode(
1830
- base64.decode(SAMLResponse),
1859
+ decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
1860
+ "utf-8",
1831
1861
  ),
1832
1862
  });
1833
1863
  throw new APIError("BAD_REQUEST", {
@@ -2137,6 +2167,14 @@ export const acsEndpoint = (options?: SSOOptions) => {
2137
2167
  const { SAMLResponse, RelayState = "" } = ctx.body;
2138
2168
  const { providerId } = ctx.params;
2139
2169
 
2170
+ const maxResponseSize =
2171
+ options?.saml?.maxResponseSize ?? DEFAULT_MAX_SAML_RESPONSE_SIZE;
2172
+ if (new TextEncoder().encode(SAMLResponse).length > maxResponseSize) {
2173
+ throw new APIError("BAD_REQUEST", {
2174
+ message: `SAML response exceeds maximum allowed size (${maxResponseSize} bytes)`,
2175
+ });
2176
+ }
2177
+
2140
2178
  // If defaultSSO is configured, use it as the provider
2141
2179
  let provider: SSOProvider<SSOOptions> | null = null;
2142
2180
 
@@ -2240,6 +2278,23 @@ export const acsEndpoint = (options?: SSOOptions) => {
2240
2278
  metadata: idpData.metadata,
2241
2279
  });
2242
2280
 
2281
+ try {
2282
+ validateSingleAssertion(SAMLResponse);
2283
+ } catch (error) {
2284
+ if (error instanceof APIError) {
2285
+ const redirectUrl =
2286
+ RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2287
+ const errorCode =
2288
+ error.body?.code === "SAML_MULTIPLE_ASSERTIONS"
2289
+ ? "multiple_assertions"
2290
+ : "no_assertion";
2291
+ throw ctx.redirect(
2292
+ `${redirectUrl}?error=${errorCode}&error_description=${encodeURIComponent(error.message)}`,
2293
+ );
2294
+ }
2295
+ throw error;
2296
+ }
2297
+
2243
2298
  // Parse and validate SAML response
2244
2299
  let parsedResponse: FlowResult;
2245
2300
  try {
@@ -2256,8 +2311,8 @@ export const acsEndpoint = (options?: SSOOptions) => {
2256
2311
  } catch (error) {
2257
2312
  ctx.context.logger.error("SAML response validation failed", {
2258
2313
  error,
2259
- decodedResponse: new TextDecoder().decode(
2260
- base64.decode(SAMLResponse),
2314
+ decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
2315
+ "utf-8",
2261
2316
  ),
2262
2317
  });
2263
2318
  throw new APIError("BAD_REQUEST", {
@@ -2353,8 +2408,8 @@ export const acsEndpoint = (options?: SSOOptions) => {
2353
2408
  }
2354
2409
 
2355
2410
  // Assertion Replay Protection
2356
- const samlContentAcs = new TextDecoder().decode(
2357
- base64.decode(SAMLResponse),
2411
+ const samlContentAcs = Buffer.from(SAMLResponse, "base64").toString(
2412
+ "utf-8",
2358
2413
  );
2359
2414
  const assertionIdAcs = extractAssertionId(samlContentAcs);
2360
2415
 
@@ -1,5 +1,5 @@
1
1
  import { APIError } from "better-auth/api";
2
- import { XMLParser } from "fast-xml-parser";
2
+ import { findNode, xmlParser } from "./parser";
3
3
 
4
4
  export const SignatureAlgorithm = {
5
5
  RSA_SHA1: "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
@@ -102,36 +102,6 @@ export interface AlgorithmValidationOptions {
102
102
  allowedDataEncryptionAlgorithms?: string[];
103
103
  }
104
104
 
105
- const xmlParser = new XMLParser({
106
- ignoreAttributes: false,
107
- attributeNamePrefix: "@_",
108
- removeNSPrefix: true,
109
- });
110
-
111
- function findNode(obj: unknown, nodeName: string): unknown {
112
- if (!obj || typeof obj !== "object") return null;
113
-
114
- const record = obj as Record<string, unknown>;
115
-
116
- if (nodeName in record) {
117
- return record[nodeName];
118
- }
119
-
120
- for (const value of Object.values(record)) {
121
- if (Array.isArray(value)) {
122
- for (const item of value) {
123
- const found = findNode(item, nodeName);
124
- if (found) return found;
125
- }
126
- } else if (typeof value === "object" && value !== null) {
127
- const found = findNode(value, nodeName);
128
- if (found) return found;
129
- }
130
- }
131
-
132
- return null;
133
- }
134
-
135
105
  function extractEncryptionAlgorithms(xml: string): {
136
106
  keyEncryption: string | null;
137
107
  dataEncryption: string | null;