@better-auth/sso 1.4.10-beta.1 → 1.4.11-beta.1
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/.turbo/turbo-build.log +7 -7
- package/dist/client.d.mts +1 -1
- package/dist/{index-CvpS40sl.d.mts → index-D4Ey-vkQ.d.mts} +35 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +113 -25
- package/package.json +4 -3
- package/src/constants.ts +16 -0
- package/src/index.ts +6 -0
- package/src/routes/sso.ts +64 -9
- package/src/saml/algorithms.ts +1 -31
- package/src/saml/assertions.test.ts +239 -0
- package/src/saml/assertions.ts +62 -0
- package/src/saml/index.ts +2 -0
- package/src/saml/parser.ts +56 -0
- package/src/saml.test.ts +361 -5
- package/src/types.ts +12 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
> @better-auth/sso@1.4.
|
|
2
|
+
> @better-auth/sso@1.4.11-beta.1 build /home/runner/work/better-auth/better-auth/packages/sso
|
|
3
3
|
> tsdown
|
|
4
4
|
|
|
5
5
|
[34mℹ[39m tsdown [2mv0.17.2[22m powered by rolldown [2mv1.0.0-beta.53[22m
|
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
[34mℹ[39m entry: [34msrc/index.ts, src/client.ts[39m
|
|
8
8
|
[34mℹ[39m tsconfig: [34mtsconfig.json[39m
|
|
9
9
|
[34mℹ[39m Build start
|
|
10
|
-
[34mℹ[39m [2mdist/[22m[1mindex.mjs[22m [
|
|
10
|
+
[34mℹ[39m [2mdist/[22m[1mindex.mjs[22m [2m99.53 kB[22m [2m│ gzip: 19.50 kB[22m
|
|
11
11
|
[34mℹ[39m [2mdist/[22m[1mclient.mjs[22m [2m 0.15 kB[22m [2m│ gzip: 0.14 kB[22m
|
|
12
|
-
[34mℹ[39m [2mdist/[22m[32m[1mindex.d.mts[22m[39m [2m 1.
|
|
13
|
-
[34mℹ[39m [2mdist/[22m[32m[1mclient.d.mts[22m[39m [2m 0.49 kB[22m [2m│ gzip: 0.
|
|
14
|
-
[34mℹ[39m [2mdist/[22m[32mindex-
|
|
15
|
-
[34mℹ[39m 5 files, total:
|
|
16
|
-
[32m✔[39m Build complete in [
|
|
12
|
+
[34mℹ[39m [2mdist/[22m[32m[1mindex.d.mts[22m[39m [2m 1.67 kB[22m [2m│ gzip: 0.57 kB[22m
|
|
13
|
+
[34mℹ[39m [2mdist/[22m[32m[1mclient.d.mts[22m[39m [2m 0.49 kB[22m [2m│ gzip: 0.30 kB[22m
|
|
14
|
+
[34mℹ[39m [2mdist/[22m[32mindex-D4Ey-vkQ.d.mts[39m [2m44.21 kB[22m [2m│ gzip: 9.10 kB[22m
|
|
15
|
+
[34mℹ[39m 5 files, total: 146.05 kB
|
|
16
|
+
[32m✔[39m Build complete in [32m23049ms[39m
|
package/dist/client.d.mts
CHANGED
|
@@ -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
|
|
@@ -1250,4 +1284,4 @@ declare function sso<O extends SSOOptions>(options?: O | undefined): {
|
|
|
1250
1284
|
endpoints: SSOEndpoints<O>;
|
|
1251
1285
|
};
|
|
1252
1286
|
//#endregion
|
|
1253
|
-
export {
|
|
1287
|
+
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
|
|
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-D4Ey-vkQ.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:
|
|
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:
|
|
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(
|
|
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.4.
|
|
4
|
+
"version": "1.4.11-beta.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.mjs",
|
|
7
7
|
"types": "dist/index.d.mts",
|
|
@@ -67,10 +67,11 @@
|
|
|
67
67
|
"express": "^5.1.0",
|
|
68
68
|
"oauth2-mock-server": "^8.2.0",
|
|
69
69
|
"tsdown": "^0.17.2",
|
|
70
|
-
"better-auth": "1.4.
|
|
70
|
+
"better-auth": "1.4.11-beta.1"
|
|
71
71
|
},
|
|
72
72
|
"peerDependencies": {
|
|
73
|
-
"better-auth": "
|
|
73
|
+
"@better-auth/utils": "0.3.0",
|
|
74
|
+
"better-auth": "1.4.11-beta.1"
|
|
74
75
|
},
|
|
75
76
|
"scripts": {
|
|
76
77
|
"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,
|
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 {
|
|
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:
|
|
1830
|
-
|
|
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:
|
|
2260
|
-
|
|
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 =
|
|
2357
|
-
|
|
2411
|
+
const samlContentAcs = Buffer.from(SAMLResponse, "base64").toString(
|
|
2412
|
+
"utf-8",
|
|
2358
2413
|
);
|
|
2359
2414
|
const assertionIdAcs = extractAssertionId(samlContentAcs);
|
|
2360
2415
|
|
package/src/saml/algorithms.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { APIError } from "better-auth/api";
|
|
2
|
-
import {
|
|
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;
|