@better-auth/sso 1.5.0-beta.2 → 1.5.0-beta.4

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.2 build /home/runner/work/better-auth/better-auth/packages/sso
2
+ > @better-auth/sso@1.5.0-beta.4 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 97.24 kB │ gzip: 18.91 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
12
  ℹ dist/index.d.mts  1.67 kB │ gzip: 0.57 kB
13
13
  ℹ dist/client.d.mts  0.49 kB │ gzip: 0.30 kB
14
- ℹ dist/index-D4Ey-vkQ.d.mts 44.21 kB │ gzip: 9.10 kB
15
- ℹ 5 files, total: 143.76 kB
16
- ✔ Build complete in 15849ms
14
+ ℹ dist/index-BLMoKtp1.d.mts 44.35 kB │ gzip: 9.16 kB
15
+ ℹ 5 files, total: 146.19 kB
16
+ ✔ Build complete in 17118ms
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-D4Ey-vkQ.mjs";
1
+ import { t as SSOPlugin } from "./index-BLMoKtp1.mjs";
2
2
 
3
3
  //#region src/client.d.ts
4
4
  interface SSOClientOptions {
@@ -1249,6 +1249,13 @@ declare function selectTokenEndpointAuthMethod(doc: OIDCDiscoveryDocument, exist
1249
1249
  declare function needsRuntimeDiscovery(config: Partial<HydratedOIDCConfig> | undefined): boolean;
1250
1250
  //#endregion
1251
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
+ }
1252
1259
  type DomainVerificationEndpoints = {
1253
1260
  requestDomainVerification: ReturnType<typeof requestDomainVerification>;
1254
1261
  verifyDomain: ReturnType<typeof verifyDomain>;
package/dist/index.d.mts CHANGED
@@ -1,2 +1,2 @@
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";
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
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
@@ -9,6 +9,7 @@ import { HIDE_METADATA, createAuthorizationURL, generateState, parseState, valid
9
9
  import { setSessionCookie } from "better-auth/cookies";
10
10
  import { handleOAuthUserInfo } from "better-auth/oauth2";
11
11
  import { decodeJwt } from "jose";
12
+ import { base64 } from "@better-auth/utils/base64";
12
13
 
13
14
  //#region src/linking/org-assignment.ts
14
15
  /**
@@ -662,6 +663,41 @@ function mapDiscoveryErrorToAPIError(error) {
662
663
  }
663
664
  }
664
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
+
665
701
  //#endregion
666
702
  //#region src/saml/algorithms.ts
667
703
  const SignatureAlgorithm = {
@@ -735,26 +771,6 @@ function normalizeSignatureAlgorithm(alg) {
735
771
  function normalizeDigestAlgorithm(alg) {
736
772
  return SHORT_FORM_DIGEST_TO_URI[alg.toLowerCase()] ?? alg;
737
773
  }
738
- const xmlParser = new XMLParser({
739
- ignoreAttributes: false,
740
- attributeNamePrefix: "@_",
741
- removeNSPrefix: true,
742
- processEntities: false
743
- });
744
- function findNode(obj, nodeName) {
745
- if (!obj || typeof obj !== "object") return null;
746
- const record = obj;
747
- if (nodeName in record) return record[nodeName];
748
- for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) {
749
- const found = findNode(item, nodeName);
750
- if (found) return found;
751
- }
752
- else if (typeof value === "object" && value !== null) {
753
- const found = findNode(value, nodeName);
754
- if (found) return found;
755
- }
756
- return null;
757
- }
758
774
  function extractEncryptionAlgorithms(xml) {
759
775
  try {
760
776
  const parsed = xmlParser.parse(xml);
@@ -863,6 +879,49 @@ function validateConfigAlgorithms(config, options = {}) {
863
879
  }
864
880
  }
865
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
+
866
925
  //#endregion
867
926
  //#region src/utils.ts
868
927
  /**
@@ -1854,6 +1913,7 @@ const callbackSSOSAML = (options) => {
1854
1913
  wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
1855
1914
  nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
1856
1915
  });
1916
+ validateSingleAssertion(SAMLResponse);
1857
1917
  let parsedResponse;
1858
1918
  try {
1859
1919
  parsedResponse = await sp.parseLoginResponse(idp, "post", { body: {
@@ -2090,6 +2150,16 @@ const acsEndpoint = (options) => {
2090
2150
  }],
2091
2151
  signingCert: idpData?.cert || parsedSamlConfig.cert
2092
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
+ }
2093
2163
  let parsedResponse;
2094
2164
  try {
2095
2165
  parsedResponse = await sp.parseLoginResponse(idp, "post", { body: {
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.2",
4
+ "version": "1.5.0-beta.4",
5
5
  "type": "module",
6
6
  "main": "dist/index.mjs",
7
7
  "types": "dist/index.d.mts",
@@ -52,6 +52,7 @@
52
52
  }
53
53
  },
54
54
  "dependencies": {
55
+ "@better-auth/utils": "0.3.0",
55
56
  "@better-fetch/fetch": "1.1.21",
56
57
  "fast-xml-parser": "^5.2.5",
57
58
  "jose": "^6.1.0",
@@ -59,22 +60,24 @@
59
60
  "zod": "^4.1.12"
60
61
  },
61
62
  "devDependencies": {
62
- "@better-auth/utils": "0.3.0",
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.2"
70
+ "@better-auth/core": "1.5.0-beta.4",
71
+ "better-auth": "1.5.0-beta.4"
71
72
  },
72
73
  "peerDependencies": {
73
- "better-auth": "1.5.0-beta.2"
74
+ "@better-auth/utils": "0.3.0",
75
+ "@better-auth/core": "1.5.0-beta.4",
76
+ "better-auth": "1.5.0-beta.4"
74
77
  },
75
78
  "scripts": {
76
79
  "test": "vitest",
77
- "coverage": "vitest run --coverage",
80
+ "coverage": "vitest run --coverage --coverage.provider=istanbul",
78
81
  "lint:package": "publint run --strict",
79
82
  "lint:types": "attw --profile esm-only --pack .",
80
83
  "build": "tsdown",
package/src/index.ts CHANGED
@@ -41,6 +41,15 @@ import type { OIDCConfig, SAMLConfig, SSOOptions, SSOProvider } from "./types";
41
41
 
42
42
  export type { SAMLConfig, OIDCConfig, SSOOptions, SSOProvider };
43
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
+
44
53
  export {
45
54
  computeDiscoveryUrl,
46
55
  type DiscoverOIDCConfigParams,
package/src/routes/sso.ts CHANGED
@@ -47,7 +47,11 @@ import {
47
47
  discoverOIDCConfig,
48
48
  mapDiscoveryErrorToAPIError,
49
49
  } from "../oidc";
50
- import { validateConfigAlgorithms, validateSAMLAlgorithms } from "../saml";
50
+ import {
51
+ validateConfigAlgorithms,
52
+ validateSAMLAlgorithms,
53
+ validateSingleAssertion,
54
+ } from "../saml";
51
55
  import type { OIDCConfig, SAMLConfig, SSOOptions, SSOProvider } from "../types";
52
56
  import { safeJsonParse, validateEmailDomain } from "../utils";
53
57
 
@@ -1835,6 +1839,8 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
1835
1839
  : undefined,
1836
1840
  });
1837
1841
 
1842
+ validateSingleAssertion(SAMLResponse);
1843
+
1838
1844
  let parsedResponse: FlowResult;
1839
1845
  try {
1840
1846
  parsedResponse = await sp.parseLoginResponse(idp, "post", {
@@ -2272,6 +2278,23 @@ export const acsEndpoint = (options?: SSOOptions) => {
2272
2278
  metadata: idpData.metadata,
2273
2279
  });
2274
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
+
2275
2298
  // Parse and validate SAML response
2276
2299
  let parsedResponse: FlowResult;
2277
2300
  try {
@@ -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,37 +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
- processEntities: false,
110
- });
111
-
112
- function findNode(obj: unknown, nodeName: string): unknown {
113
- if (!obj || typeof obj !== "object") return null;
114
-
115
- const record = obj as Record<string, unknown>;
116
-
117
- if (nodeName in record) {
118
- return record[nodeName];
119
- }
120
-
121
- for (const value of Object.values(record)) {
122
- if (Array.isArray(value)) {
123
- for (const item of value) {
124
- const found = findNode(item, nodeName);
125
- if (found) return found;
126
- }
127
- } else if (typeof value === "object" && value !== null) {
128
- const found = findNode(value, nodeName);
129
- if (found) return found;
130
- }
131
- }
132
-
133
- return null;
134
- }
135
-
136
105
  function extractEncryptionAlgorithms(xml: string): {
137
106
  keyEncryption: string | null;
138
107
  dataEncryption: string | null;
@@ -0,0 +1,239 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { countAssertions, validateSingleAssertion } from "./assertions";
3
+
4
+ describe("validateSingleAssertion", () => {
5
+ const encode = (xml: string) => Buffer.from(xml).toString("base64");
6
+
7
+ describe("valid responses (exactly 1 assertion)", () => {
8
+ it("should accept response with single assertion", () => {
9
+ const xml = `
10
+ <samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
11
+ <saml:Assertion ID="123">
12
+ <saml:Subject><saml:NameID>user@example.com</saml:NameID></saml:Subject>
13
+ </saml:Assertion>
14
+ </samlp:Response>
15
+ `;
16
+ expect(() => validateSingleAssertion(encode(xml))).not.toThrow();
17
+ });
18
+
19
+ it("should accept response with single encrypted assertion", () => {
20
+ const xml = `
21
+ <samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
22
+ <saml:EncryptedAssertion>
23
+ <xenc:EncryptedData>...</xenc:EncryptedData>
24
+ </saml:EncryptedAssertion>
25
+ </samlp:Response>
26
+ `;
27
+ expect(() => validateSingleAssertion(encode(xml))).not.toThrow();
28
+ });
29
+ });
30
+
31
+ describe("no assertions", () => {
32
+ it("should reject response with no assertions", () => {
33
+ const xml = `
34
+ <samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol">
35
+ <samlp:Status>
36
+ <samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
37
+ </samlp:Status>
38
+ </samlp:Response>
39
+ `;
40
+ expect(() => validateSingleAssertion(encode(xml))).toThrow(
41
+ "SAML response contains no assertions",
42
+ );
43
+ });
44
+ });
45
+
46
+ describe("multiple assertions", () => {
47
+ it("should reject response with multiple unencrypted assertions", () => {
48
+ const xml = `
49
+ <samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
50
+ <saml:Assertion ID="assertion1">
51
+ <saml:Subject><saml:NameID>user@example.com</saml:NameID></saml:Subject>
52
+ </saml:Assertion>
53
+ <saml:Assertion ID="assertion2">
54
+ <saml:Subject><saml:NameID>attacker@evil.com</saml:NameID></saml:Subject>
55
+ </saml:Assertion>
56
+ </samlp:Response>
57
+ `;
58
+ expect(() => validateSingleAssertion(encode(xml))).toThrow(
59
+ "SAML response contains 2 assertions, expected exactly 1",
60
+ );
61
+ });
62
+
63
+ it("should reject response with multiple encrypted assertions", () => {
64
+ const xml = `
65
+ <samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
66
+ <saml:EncryptedAssertion>
67
+ <xenc:EncryptedData>...</xenc:EncryptedData>
68
+ </saml:EncryptedAssertion>
69
+ <saml:EncryptedAssertion>
70
+ <xenc:EncryptedData>...</xenc:EncryptedData>
71
+ </saml:EncryptedAssertion>
72
+ </samlp:Response>
73
+ `;
74
+ expect(() => validateSingleAssertion(encode(xml))).toThrow(
75
+ "SAML response contains 2 assertions, expected exactly 1",
76
+ );
77
+ });
78
+
79
+ it("should reject response with mixed assertion types", () => {
80
+ const xml = `
81
+ <samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
82
+ <saml:Assertion ID="plain-assertion">
83
+ <saml:Subject><saml:NameID>user@example.com</saml:NameID></saml:Subject>
84
+ </saml:Assertion>
85
+ <saml:EncryptedAssertion>
86
+ <xenc:EncryptedData>...</xenc:EncryptedData>
87
+ </saml:EncryptedAssertion>
88
+ </samlp:Response>
89
+ `;
90
+ expect(() => validateSingleAssertion(encode(xml))).toThrow(
91
+ "SAML response contains 2 assertions, expected exactly 1",
92
+ );
93
+ });
94
+ });
95
+
96
+ describe("XSW attack patterns", () => {
97
+ it("should reject assertion injected in Extensions element", () => {
98
+ const xml = `
99
+ <samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
100
+ <samlp:Extensions>
101
+ <saml:Assertion ID="injected-assertion">
102
+ <saml:Subject><saml:NameID>attacker@evil.com</saml:NameID></saml:Subject>
103
+ </saml:Assertion>
104
+ </samlp:Extensions>
105
+ <saml:Assertion ID="legitimate-assertion">
106
+ <saml:Subject><saml:NameID>user@example.com</saml:NameID></saml:Subject>
107
+ </saml:Assertion>
108
+ </samlp:Response>
109
+ `;
110
+ expect(() => validateSingleAssertion(encode(xml))).toThrow(
111
+ "SAML response contains 2 assertions, expected exactly 1",
112
+ );
113
+ });
114
+
115
+ it("should reject assertion wrapped in arbitrary element", () => {
116
+ const xml = `
117
+ <samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
118
+ <Wrapper>
119
+ <saml:Assertion ID="wrapped-assertion">
120
+ <saml:Subject><saml:NameID>attacker@evil.com</saml:NameID></saml:Subject>
121
+ </saml:Assertion>
122
+ </Wrapper>
123
+ <saml:Assertion ID="legitimate-assertion">
124
+ <saml:Subject><saml:NameID>user@example.com</saml:NameID></saml:Subject>
125
+ </saml:Assertion>
126
+ </samlp:Response>
127
+ `;
128
+ expect(() => validateSingleAssertion(encode(xml))).toThrow(
129
+ "SAML response contains 2 assertions, expected exactly 1",
130
+ );
131
+ });
132
+
133
+ it("should reject deeply nested injected assertion", () => {
134
+ const xml = `
135
+ <samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
136
+ <Level1>
137
+ <Level2>
138
+ <Level3>
139
+ <saml:Assertion ID="deep-injected">
140
+ <saml:Subject><saml:NameID>attacker@evil.com</saml:NameID></saml:Subject>
141
+ </saml:Assertion>
142
+ </Level3>
143
+ </Level2>
144
+ </Level1>
145
+ <saml:Assertion ID="legitimate-assertion">
146
+ <saml:Subject><saml:NameID>user@example.com</saml:NameID></saml:Subject>
147
+ </saml:Assertion>
148
+ </samlp:Response>
149
+ `;
150
+ expect(() => validateSingleAssertion(encode(xml))).toThrow(
151
+ "SAML response contains 2 assertions, expected exactly 1",
152
+ );
153
+ });
154
+ });
155
+
156
+ describe("namespace handling", () => {
157
+ it("should handle assertion without namespace prefix", () => {
158
+ const xml = `
159
+ <Response>
160
+ <Assertion ID="123">
161
+ <Subject><NameID>user@example.com</NameID></Subject>
162
+ </Assertion>
163
+ </Response>
164
+ `;
165
+ expect(() => validateSingleAssertion(encode(xml))).not.toThrow();
166
+ });
167
+
168
+ it("should handle assertion with saml2: prefix", () => {
169
+ const xml = `
170
+ <saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">
171
+ <saml2:Assertion ID="123">
172
+ <saml2:Subject><saml2:NameID>user@example.com</saml2:NameID></saml2:Subject>
173
+ </saml2:Assertion>
174
+ </saml2p:Response>
175
+ `;
176
+ expect(() => validateSingleAssertion(encode(xml))).not.toThrow();
177
+ });
178
+
179
+ it("should handle assertion with custom prefix", () => {
180
+ const xml = `
181
+ <custom:Response xmlns:custom="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:myprefix="urn:oasis:names:tc:SAML:2.0:assertion">
182
+ <myprefix:Assertion ID="123">
183
+ <myprefix:Subject><myprefix:NameID>user@example.com</myprefix:NameID></myprefix:Subject>
184
+ </myprefix:Assertion>
185
+ </custom:Response>
186
+ `;
187
+ expect(() => validateSingleAssertion(encode(xml))).not.toThrow();
188
+ });
189
+ });
190
+ });
191
+
192
+ describe("countAssertions", () => {
193
+ it("should return separate counts for assertions and encrypted assertions", () => {
194
+ const xml = `
195
+ <samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
196
+ <saml:Assertion ID="plain">
197
+ <saml:Subject><saml:NameID>user@example.com</saml:NameID></saml:Subject>
198
+ </saml:Assertion>
199
+ <saml:EncryptedAssertion>
200
+ <xenc:EncryptedData>...</xenc:EncryptedData>
201
+ </saml:EncryptedAssertion>
202
+ </samlp:Response>
203
+ `;
204
+ const counts = countAssertions(xml);
205
+ expect(counts.assertions).toBe(1);
206
+ expect(counts.encryptedAssertions).toBe(1);
207
+ expect(counts.total).toBe(2);
208
+ });
209
+
210
+ it("should not count AssertionConsumerService as assertion", () => {
211
+ const xml = `
212
+ <md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata">
213
+ <md:SPSSODescriptor>
214
+ <md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="http://example.com/acs"/>
215
+ </md:SPSSODescriptor>
216
+ </md:EntityDescriptor>
217
+ `;
218
+ const counts = countAssertions(xml);
219
+ expect(counts.assertions).toBe(0);
220
+ expect(counts.total).toBe(0);
221
+ });
222
+ });
223
+
224
+ describe("error handling", () => {
225
+ const encode = (str: string) => Buffer.from(str).toString("base64");
226
+
227
+ it("should reject invalid base64 input", () => {
228
+ expect(() => validateSingleAssertion("not-valid-base64!!!")).toThrow(
229
+ "Invalid base64-encoded SAML response",
230
+ );
231
+ });
232
+
233
+ it("should reject non-XML content", () => {
234
+ const notXml = encode("this is not xml at all");
235
+ expect(() => validateSingleAssertion(notXml)).toThrow(
236
+ "Invalid base64-encoded SAML response",
237
+ );
238
+ });
239
+ });
@@ -0,0 +1,62 @@
1
+ import { base64 } from "@better-auth/utils/base64";
2
+ import { APIError } from "better-auth/api";
3
+ import { countAllNodes, xmlParser } from "./parser";
4
+
5
+ export interface AssertionCounts {
6
+ assertions: number;
7
+ encryptedAssertions: number;
8
+ total: number;
9
+ }
10
+
11
+ /** @lintignore used in tests */
12
+ export function countAssertions(xml: string): AssertionCounts {
13
+ let parsed: unknown;
14
+ try {
15
+ parsed = xmlParser.parse(xml);
16
+ } catch {
17
+ throw new APIError("BAD_REQUEST", {
18
+ message: "Failed to parse SAML response XML",
19
+ code: "SAML_INVALID_XML",
20
+ });
21
+ }
22
+
23
+ const assertions = countAllNodes(parsed, "Assertion");
24
+ const encryptedAssertions = countAllNodes(parsed, "EncryptedAssertion");
25
+
26
+ return {
27
+ assertions,
28
+ encryptedAssertions,
29
+ total: assertions + encryptedAssertions,
30
+ };
31
+ }
32
+
33
+ export function validateSingleAssertion(samlResponse: string): void {
34
+ let xml: string;
35
+ try {
36
+ xml = new TextDecoder().decode(base64.decode(samlResponse));
37
+ if (!xml.includes("<")) {
38
+ throw new Error("Not XML");
39
+ }
40
+ } catch {
41
+ throw new APIError("BAD_REQUEST", {
42
+ message: "Invalid base64-encoded SAML response",
43
+ code: "SAML_INVALID_ENCODING",
44
+ });
45
+ }
46
+
47
+ const counts = countAssertions(xml);
48
+
49
+ if (counts.total === 0) {
50
+ throw new APIError("BAD_REQUEST", {
51
+ message: "SAML response contains no assertions",
52
+ code: "SAML_NO_ASSERTION",
53
+ });
54
+ }
55
+
56
+ if (counts.total > 1) {
57
+ throw new APIError("BAD_REQUEST", {
58
+ message: `SAML response contains ${counts.total} assertions, expected exactly 1`,
59
+ code: "SAML_MULTIPLE_ASSERTIONS",
60
+ });
61
+ }
62
+ }
package/src/saml/index.ts CHANGED
@@ -9,3 +9,5 @@ export {
9
9
  validateConfigAlgorithms,
10
10
  validateSAMLAlgorithms,
11
11
  } from "./algorithms";
12
+
13
+ export { validateSingleAssertion } from "./assertions";
@@ -0,0 +1,56 @@
1
+ import { XMLParser } from "fast-xml-parser";
2
+
3
+ export const xmlParser = new XMLParser({
4
+ ignoreAttributes: false,
5
+ attributeNamePrefix: "@_",
6
+ removeNSPrefix: true,
7
+ processEntities: false,
8
+ });
9
+
10
+ export function findNode(obj: unknown, nodeName: string): unknown {
11
+ if (!obj || typeof obj !== "object") return null;
12
+
13
+ const record = obj as Record<string, unknown>;
14
+
15
+ if (nodeName in record) {
16
+ return record[nodeName];
17
+ }
18
+
19
+ for (const value of Object.values(record)) {
20
+ if (Array.isArray(value)) {
21
+ for (const item of value) {
22
+ const found = findNode(item, nodeName);
23
+ if (found) return found;
24
+ }
25
+ } else if (typeof value === "object" && value !== null) {
26
+ const found = findNode(value, nodeName);
27
+ if (found) return found;
28
+ }
29
+ }
30
+
31
+ return null;
32
+ }
33
+
34
+ export function countAllNodes(obj: unknown, nodeName: string): number {
35
+ if (!obj || typeof obj !== "object") return 0;
36
+
37
+ let count = 0;
38
+ const record = obj as Record<string, unknown>;
39
+
40
+ if (nodeName in record) {
41
+ const node = record[nodeName];
42
+ count += Array.isArray(node) ? node.length : 1;
43
+ }
44
+
45
+ for (const value of Object.values(record)) {
46
+ if (Array.isArray(value)) {
47
+ for (const item of value) {
48
+ count += countAllNodes(item, nodeName);
49
+ }
50
+ } else if (typeof value === "object" && value !== null) {
51
+ count += countAllNodes(value, nodeName);
52
+ }
53
+ }
54
+
55
+ return count;
56
+ }
package/src/saml.test.ts CHANGED
@@ -2614,3 +2614,351 @@ describe("SAML SSO - Assertion Replay Protection", () => {
2614
2614
  expect(acsLocation).toContain("error=replay_detected");
2615
2615
  });
2616
2616
  });
2617
+
2618
+ describe("SAML SSO - Single Assertion Validation", () => {
2619
+ it("should reject SAML response with multiple assertions on callback endpoint", async () => {
2620
+ const { auth, signInWithTestUser } = await getTestInstance({
2621
+ plugins: [sso()],
2622
+ });
2623
+
2624
+ const { headers } = await signInWithTestUser();
2625
+
2626
+ await auth.api.registerSSOProvider({
2627
+ body: {
2628
+ providerId: "multi-assertion-callback-provider",
2629
+ issuer: "http://localhost:8081",
2630
+ domain: "http://localhost:8081",
2631
+ samlConfig: {
2632
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
2633
+ cert: certificate,
2634
+ callbackUrl: "http://localhost:3000/dashboard",
2635
+ wantAssertionsSigned: false,
2636
+ signatureAlgorithm: "sha256",
2637
+ digestAlgorithm: "sha256",
2638
+ idpMetadata: {
2639
+ metadata: idpMetadata,
2640
+ },
2641
+ spMetadata: {
2642
+ metadata: spMetadata,
2643
+ },
2644
+ identifierFormat:
2645
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
2646
+ },
2647
+ },
2648
+ headers,
2649
+ });
2650
+
2651
+ const multiAssertionResponse = `
2652
+ <saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">
2653
+ <saml2:Issuer>http://localhost:8081</saml2:Issuer>
2654
+ <saml2p:Status>
2655
+ <saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
2656
+ </saml2p:Status>
2657
+ <saml2:Assertion ID="assertion-1">
2658
+ <saml2:Issuer>http://localhost:8081</saml2:Issuer>
2659
+ <saml2:Subject>
2660
+ <saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">legitimate@example.com</saml2:NameID>
2661
+ </saml2:Subject>
2662
+ </saml2:Assertion>
2663
+ <saml2:Assertion ID="assertion-2">
2664
+ <saml2:Issuer>http://localhost:8081</saml2:Issuer>
2665
+ <saml2:Subject>
2666
+ <saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">attacker@evil.com</saml2:NameID>
2667
+ </saml2:Subject>
2668
+ </saml2:Assertion>
2669
+ </saml2p:Response>
2670
+ `;
2671
+
2672
+ const encodedResponse = Buffer.from(multiAssertionResponse).toString(
2673
+ "base64",
2674
+ );
2675
+
2676
+ await expect(
2677
+ auth.api.callbackSSOSAML({
2678
+ body: {
2679
+ SAMLResponse: encodedResponse,
2680
+ RelayState: "http://localhost:3000/dashboard",
2681
+ },
2682
+ params: {
2683
+ providerId: "multi-assertion-callback-provider",
2684
+ },
2685
+ }),
2686
+ ).rejects.toMatchObject({
2687
+ body: {
2688
+ code: "SAML_MULTIPLE_ASSERTIONS",
2689
+ },
2690
+ });
2691
+ });
2692
+
2693
+ it("should reject SAML response with multiple assertions on ACS endpoint", async () => {
2694
+ const { auth, signInWithTestUser } = await getTestInstance({
2695
+ plugins: [sso()],
2696
+ });
2697
+
2698
+ const { headers } = await signInWithTestUser();
2699
+
2700
+ await auth.api.registerSSOProvider({
2701
+ body: {
2702
+ providerId: "multi-assertion-acs-provider",
2703
+ issuer: "http://localhost:8081",
2704
+ domain: "http://localhost:8081",
2705
+ samlConfig: {
2706
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
2707
+ cert: certificate,
2708
+ callbackUrl: "http://localhost:3000/dashboard",
2709
+ wantAssertionsSigned: false,
2710
+ signatureAlgorithm: "sha256",
2711
+ digestAlgorithm: "sha256",
2712
+ idpMetadata: {
2713
+ metadata: idpMetadata,
2714
+ },
2715
+ spMetadata: {
2716
+ metadata: spMetadata,
2717
+ },
2718
+ identifierFormat:
2719
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
2720
+ },
2721
+ },
2722
+ headers,
2723
+ });
2724
+
2725
+ const multiAssertionResponse = `
2726
+ <saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">
2727
+ <saml2:Issuer>http://localhost:8081</saml2:Issuer>
2728
+ <saml2p:Status>
2729
+ <saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
2730
+ </saml2p:Status>
2731
+ <saml2:Assertion ID="assertion-1">
2732
+ <saml2:Issuer>http://localhost:8081</saml2:Issuer>
2733
+ <saml2:Subject>
2734
+ <saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">legitimate@example.com</saml2:NameID>
2735
+ </saml2:Subject>
2736
+ </saml2:Assertion>
2737
+ <saml2:Assertion ID="assertion-2">
2738
+ <saml2:Issuer>http://localhost:8081</saml2:Issuer>
2739
+ <saml2:Subject>
2740
+ <saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">attacker@evil.com</saml2:NameID>
2741
+ </saml2:Subject>
2742
+ </saml2:Assertion>
2743
+ </saml2p:Response>
2744
+ `;
2745
+
2746
+ const encodedResponse = Buffer.from(multiAssertionResponse).toString(
2747
+ "base64",
2748
+ );
2749
+
2750
+ const response = await auth.handler(
2751
+ new Request(
2752
+ "http://localhost:3000/api/auth/sso/saml2/sp/acs/multi-assertion-acs-provider",
2753
+ {
2754
+ method: "POST",
2755
+ headers: {
2756
+ "Content-Type": "application/x-www-form-urlencoded",
2757
+ },
2758
+ body: new URLSearchParams({
2759
+ SAMLResponse: encodedResponse,
2760
+ RelayState: "http://localhost:3000/dashboard",
2761
+ }),
2762
+ },
2763
+ ),
2764
+ );
2765
+
2766
+ expect(response.status).toBe(302);
2767
+ const location = response.headers.get("location") || "";
2768
+ expect(location).toContain("error=multiple_assertions");
2769
+ });
2770
+
2771
+ it("should reject SAML response with no assertions", async () => {
2772
+ const { auth, signInWithTestUser } = await getTestInstance({
2773
+ plugins: [sso()],
2774
+ });
2775
+
2776
+ const { headers } = await signInWithTestUser();
2777
+
2778
+ await auth.api.registerSSOProvider({
2779
+ body: {
2780
+ providerId: "no-assertion-provider",
2781
+ issuer: "http://localhost:8081",
2782
+ domain: "http://localhost:8081",
2783
+ samlConfig: {
2784
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
2785
+ cert: certificate,
2786
+ callbackUrl: "http://localhost:3000/dashboard",
2787
+ wantAssertionsSigned: false,
2788
+ signatureAlgorithm: "sha256",
2789
+ digestAlgorithm: "sha256",
2790
+ idpMetadata: {
2791
+ metadata: idpMetadata,
2792
+ },
2793
+ spMetadata: {
2794
+ metadata: spMetadata,
2795
+ },
2796
+ identifierFormat:
2797
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
2798
+ },
2799
+ },
2800
+ headers,
2801
+ });
2802
+
2803
+ const noAssertionResponse = `
2804
+ <saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">
2805
+ <saml2:Issuer>http://localhost:8081</saml2:Issuer>
2806
+ <saml2p:Status>
2807
+ <saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
2808
+ </saml2p:Status>
2809
+ </saml2p:Response>
2810
+ `;
2811
+
2812
+ const encodedResponse = Buffer.from(noAssertionResponse).toString("base64");
2813
+
2814
+ await expect(
2815
+ auth.api.callbackSSOSAML({
2816
+ body: {
2817
+ SAMLResponse: encodedResponse,
2818
+ RelayState: "http://localhost:3000/dashboard",
2819
+ },
2820
+ params: {
2821
+ providerId: "no-assertion-provider",
2822
+ },
2823
+ }),
2824
+ ).rejects.toMatchObject({
2825
+ body: {
2826
+ code: "SAML_NO_ASSERTION",
2827
+ },
2828
+ });
2829
+ });
2830
+
2831
+ it("should reject SAML response with XSW-style assertion injection in Extensions", async () => {
2832
+ const { auth, signInWithTestUser } = await getTestInstance({
2833
+ plugins: [sso()],
2834
+ });
2835
+
2836
+ const { headers } = await signInWithTestUser();
2837
+
2838
+ await auth.api.registerSSOProvider({
2839
+ body: {
2840
+ providerId: "xsw-injection-provider",
2841
+ issuer: "http://localhost:8081",
2842
+ domain: "http://localhost:8081",
2843
+ samlConfig: {
2844
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
2845
+ cert: certificate,
2846
+ callbackUrl: "http://localhost:3000/dashboard",
2847
+ wantAssertionsSigned: false,
2848
+ signatureAlgorithm: "sha256",
2849
+ digestAlgorithm: "sha256",
2850
+ idpMetadata: {
2851
+ metadata: idpMetadata,
2852
+ },
2853
+ spMetadata: {
2854
+ metadata: spMetadata,
2855
+ },
2856
+ identifierFormat:
2857
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
2858
+ },
2859
+ },
2860
+ headers,
2861
+ });
2862
+
2863
+ const xswInjectionResponse = `
2864
+ <saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">
2865
+ <saml2:Issuer>http://localhost:8081</saml2:Issuer>
2866
+ <saml2p:Status>
2867
+ <saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
2868
+ </saml2p:Status>
2869
+ <saml2p:Extensions>
2870
+ <saml2:Assertion ID="injected-assertion">
2871
+ <saml2:Issuer>http://localhost:8081</saml2:Issuer>
2872
+ <saml2:Subject>
2873
+ <saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">attacker@evil.com</saml2:NameID>
2874
+ </saml2:Subject>
2875
+ </saml2:Assertion>
2876
+ </saml2p:Extensions>
2877
+ <saml2:Assertion ID="legitimate-assertion">
2878
+ <saml2:Issuer>http://localhost:8081</saml2:Issuer>
2879
+ <saml2:Subject>
2880
+ <saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">user@example.com</saml2:NameID>
2881
+ </saml2:Subject>
2882
+ </saml2:Assertion>
2883
+ </saml2p:Response>
2884
+ `;
2885
+
2886
+ const encodedResponse =
2887
+ Buffer.from(xswInjectionResponse).toString("base64");
2888
+
2889
+ await expect(
2890
+ auth.api.callbackSSOSAML({
2891
+ body: {
2892
+ SAMLResponse: encodedResponse,
2893
+ RelayState: "http://localhost:3000/dashboard",
2894
+ },
2895
+ params: {
2896
+ providerId: "xsw-injection-provider",
2897
+ },
2898
+ }),
2899
+ ).rejects.toMatchObject({
2900
+ body: {
2901
+ code: "SAML_MULTIPLE_ASSERTIONS",
2902
+ },
2903
+ });
2904
+ });
2905
+
2906
+ it("should accept valid SAML response with exactly one assertion", async () => {
2907
+ const { auth, signInWithTestUser } = await getTestInstance({
2908
+ plugins: [sso()],
2909
+ });
2910
+
2911
+ const { headers } = await signInWithTestUser();
2912
+
2913
+ await auth.api.registerSSOProvider({
2914
+ body: {
2915
+ providerId: "single-assertion-provider",
2916
+ issuer: "http://localhost:8081",
2917
+ domain: "http://localhost:8081",
2918
+ samlConfig: {
2919
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
2920
+ cert: certificate,
2921
+ callbackUrl: "http://localhost:3000/dashboard",
2922
+ wantAssertionsSigned: false,
2923
+ signatureAlgorithm: "sha256",
2924
+ digestAlgorithm: "sha256",
2925
+ idpMetadata: {
2926
+ metadata: idpMetadata,
2927
+ },
2928
+ spMetadata: {
2929
+ metadata: spMetadata,
2930
+ },
2931
+ identifierFormat:
2932
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
2933
+ },
2934
+ },
2935
+ headers,
2936
+ });
2937
+
2938
+ let samlResponse: any;
2939
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
2940
+ onSuccess: async (context) => {
2941
+ samlResponse = await context.data;
2942
+ },
2943
+ });
2944
+
2945
+ const response = await auth.handler(
2946
+ new Request(
2947
+ "http://localhost:3000/api/auth/sso/saml2/callback/single-assertion-provider",
2948
+ {
2949
+ method: "POST",
2950
+ headers: {
2951
+ "Content-Type": "application/x-www-form-urlencoded",
2952
+ },
2953
+ body: new URLSearchParams({
2954
+ SAMLResponse: samlResponse.samlResponse,
2955
+ RelayState: "http://localhost:3000/dashboard",
2956
+ }),
2957
+ },
2958
+ ),
2959
+ );
2960
+
2961
+ expect(response.status).toBe(302);
2962
+ expect(response.headers.get("location")).not.toContain("error");
2963
+ });
2964
+ });
package/tsconfig.json CHANGED
@@ -6,6 +6,9 @@
6
6
  "references": [
7
7
  {
8
8
  "path": "../better-auth/tsconfig.json"
9
+ },
10
+ {
11
+ "path": "../core/tsconfig.json"
9
12
  }
10
13
  ]
11
14
  }