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

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.
@@ -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
+ }
@@ -0,0 +1,78 @@
1
+ import type { GenericEndpointContext, StateData } from "better-auth";
2
+ import { generateGenericState, parseGenericState } from "better-auth";
3
+ import { generateRandomString } from "better-auth/crypto";
4
+ import { APIError } from "better-call";
5
+
6
+ export async function generateRelayState(
7
+ c: GenericEndpointContext,
8
+ link:
9
+ | {
10
+ email: string;
11
+ userId: string;
12
+ }
13
+ | undefined,
14
+ additionalData: Record<string, any> | false | undefined,
15
+ ) {
16
+ const callbackURL = c.body.callbackURL;
17
+ if (!callbackURL) {
18
+ throw new APIError("BAD_REQUEST", {
19
+ message: "callbackURL is required",
20
+ });
21
+ }
22
+
23
+ const codeVerifier = generateRandomString(128);
24
+ const stateData: StateData = {
25
+ ...(additionalData ? additionalData : {}),
26
+ callbackURL,
27
+ codeVerifier,
28
+ errorURL: c.body.errorCallbackURL,
29
+ newUserURL: c.body.newUserCallbackURL,
30
+ link,
31
+ /**
32
+ * This is the actual expiry time of the state
33
+ */
34
+ expiresAt: Date.now() + 10 * 60 * 1000,
35
+ requestSignUp: c.body.requestSignUp,
36
+ };
37
+
38
+ try {
39
+ return generateGenericState(c, stateData, {
40
+ cookieName: "relay_state",
41
+ });
42
+ } catch (error) {
43
+ c.context.logger.error(
44
+ "Failed to create verification for relay state",
45
+ error,
46
+ );
47
+ throw new APIError("INTERNAL_SERVER_ERROR", {
48
+ message: "State error: Unable to create verification for relay state",
49
+ cause: error,
50
+ });
51
+ }
52
+ }
53
+
54
+ export async function parseRelayState(c: GenericEndpointContext) {
55
+ const state = c.body.RelayState;
56
+ const errorURL =
57
+ c.context.options.onAPIError?.errorURL || `${c.context.baseURL}/error`;
58
+
59
+ let parsedData: StateData;
60
+
61
+ try {
62
+ parsedData = await parseGenericState(c, state, {
63
+ cookieName: "relay_state",
64
+ });
65
+ } catch (error) {
66
+ c.context.logger.error("Failed to parse relay state", error);
67
+ throw new APIError("BAD_REQUEST", {
68
+ message: "State error: failed to validate relay state",
69
+ cause: error,
70
+ });
71
+ }
72
+
73
+ if (!parsedData.errorURL) {
74
+ parsedData.errorURL = errorURL;
75
+ }
76
+
77
+ return parsedData;
78
+ }