@better-auth/sso 1.4.7-beta.4 → 1.4.8-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.
@@ -0,0 +1,205 @@
1
+ /* cspell:ignore xenc */
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import * as alg from "./algorithms";
4
+
5
+ const encryptedAssertionXml = `
6
+ <samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol">
7
+ <saml:EncryptedAssertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
8
+ <xenc:EncryptedData xmlns:xenc="http://www.w3.org/2001/04/xmlenc#">
9
+ <xenc:EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#aes256-cbc"/>
10
+ <ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
11
+ <xenc:EncryptedKey>
12
+ <xenc:EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p"/>
13
+ </xenc:EncryptedKey>
14
+ </ds:KeyInfo>
15
+ </xenc:EncryptedData>
16
+ </saml:EncryptedAssertion>
17
+ </samlp:Response>
18
+ `;
19
+
20
+ const deprecatedEncryptionXml = `
21
+ <samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol">
22
+ <saml:EncryptedAssertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
23
+ <xenc:EncryptedData xmlns:xenc="http://www.w3.org/2001/04/xmlenc#">
24
+ <xenc:EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#tripledes-cbc"/>
25
+ <ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
26
+ <xenc:EncryptedKey>
27
+ <xenc:EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#rsa-1_5"/>
28
+ </xenc:EncryptedKey>
29
+ </ds:KeyInfo>
30
+ </xenc:EncryptedData>
31
+ </saml:EncryptedAssertion>
32
+ </samlp:Response>
33
+ `;
34
+
35
+ const plainAssertionXml = `
36
+ <samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol">
37
+ <saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
38
+ <saml:Subject>test</saml:Subject>
39
+ </saml:Assertion>
40
+ </samlp:Response>
41
+ `;
42
+
43
+ describe("validateSAMLAlgorithms", () => {
44
+ afterEach(() => {
45
+ vi.restoreAllMocks();
46
+ });
47
+
48
+ describe("signature validation", () => {
49
+ it("should accept secure signature algorithms", () => {
50
+ expect(() =>
51
+ alg.validateSAMLAlgorithms({
52
+ sigAlg: alg.SignatureAlgorithm.RSA_SHA256,
53
+ samlContent: plainAssertionXml,
54
+ }),
55
+ ).not.toThrow();
56
+ });
57
+
58
+ it("should warn by default for deprecated signature algorithms", () => {
59
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
60
+
61
+ expect(() =>
62
+ alg.validateSAMLAlgorithms({
63
+ sigAlg: alg.SignatureAlgorithm.RSA_SHA1,
64
+ samlContent: plainAssertionXml,
65
+ }),
66
+ ).not.toThrow();
67
+
68
+ expect(warnSpy).toHaveBeenCalledWith(
69
+ expect.stringContaining("SAML Security Warning"),
70
+ );
71
+ });
72
+
73
+ it("should reject deprecated signature with onDeprecated: reject", () => {
74
+ expect(() =>
75
+ alg.validateSAMLAlgorithms(
76
+ {
77
+ sigAlg: alg.SignatureAlgorithm.RSA_SHA1,
78
+ samlContent: plainAssertionXml,
79
+ },
80
+ { onDeprecated: "reject" },
81
+ ),
82
+ ).toThrow(/deprecated/i);
83
+ });
84
+
85
+ it("should silently allow deprecated with onDeprecated: allow", () => {
86
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
87
+
88
+ expect(() =>
89
+ alg.validateSAMLAlgorithms(
90
+ {
91
+ sigAlg: alg.SignatureAlgorithm.RSA_SHA1,
92
+ samlContent: plainAssertionXml,
93
+ },
94
+ { onDeprecated: "allow" },
95
+ ),
96
+ ).not.toThrow();
97
+
98
+ expect(warnSpy).not.toHaveBeenCalled();
99
+ });
100
+
101
+ it("should enforce custom signature allow-list", () => {
102
+ expect(() =>
103
+ alg.validateSAMLAlgorithms(
104
+ {
105
+ sigAlg: alg.SignatureAlgorithm.RSA_SHA256,
106
+ samlContent: plainAssertionXml,
107
+ },
108
+ { allowedSignatureAlgorithms: [alg.SignatureAlgorithm.RSA_SHA512] },
109
+ ),
110
+ ).toThrow(/not in allow-list/i);
111
+ });
112
+
113
+ it("should pass null/undefined sigAlg without error", () => {
114
+ expect(() =>
115
+ alg.validateSAMLAlgorithms({
116
+ sigAlg: null,
117
+ samlContent: plainAssertionXml,
118
+ }),
119
+ ).not.toThrow();
120
+ });
121
+
122
+ it("should reject unknown signature algorithms", () => {
123
+ expect(() =>
124
+ alg.validateSAMLAlgorithms({
125
+ sigAlg: "http://example.com/unknown-algo",
126
+ samlContent: plainAssertionXml,
127
+ }),
128
+ ).toThrow(/not recognized/i);
129
+ });
130
+ });
131
+
132
+ describe("encryption validation", () => {
133
+ it("should accept secure encryption algorithms", () => {
134
+ expect(() =>
135
+ alg.validateSAMLAlgorithms({
136
+ sigAlg: alg.SignatureAlgorithm.RSA_SHA256,
137
+ samlContent: encryptedAssertionXml,
138
+ }),
139
+ ).not.toThrow();
140
+ });
141
+
142
+ it("should warn by default for deprecated encryption algorithms", () => {
143
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
144
+
145
+ expect(() =>
146
+ alg.validateSAMLAlgorithms({
147
+ sigAlg: alg.SignatureAlgorithm.RSA_SHA256,
148
+ samlContent: deprecatedEncryptionXml,
149
+ }),
150
+ ).not.toThrow();
151
+
152
+ expect(warnSpy).toHaveBeenCalled();
153
+ });
154
+
155
+ it("should reject deprecated encryption with onDeprecated: reject", () => {
156
+ expect(() =>
157
+ alg.validateSAMLAlgorithms(
158
+ {
159
+ sigAlg: alg.SignatureAlgorithm.RSA_SHA256,
160
+ samlContent: deprecatedEncryptionXml,
161
+ },
162
+ { onDeprecated: "reject" },
163
+ ),
164
+ ).toThrow(/deprecated/i);
165
+ });
166
+
167
+ it("should skip encryption validation for plain assertions", () => {
168
+ expect(() =>
169
+ alg.validateSAMLAlgorithms({
170
+ sigAlg: alg.SignatureAlgorithm.RSA_SHA256,
171
+ samlContent: plainAssertionXml,
172
+ }),
173
+ ).not.toThrow();
174
+ });
175
+
176
+ it("should handle malformed XML gracefully", () => {
177
+ expect(() =>
178
+ alg.validateSAMLAlgorithms({
179
+ sigAlg: alg.SignatureAlgorithm.RSA_SHA256,
180
+ samlContent: "not valid xml",
181
+ }),
182
+ ).not.toThrow();
183
+ });
184
+ });
185
+ });
186
+
187
+ describe("algorithm constants", () => {
188
+ it("should export signature algorithm constants", () => {
189
+ expect(alg.SignatureAlgorithm.RSA_SHA256).toBe(
190
+ "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
191
+ );
192
+ expect(alg.SignatureAlgorithm.RSA_SHA1).toBe(
193
+ "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
194
+ );
195
+ });
196
+
197
+ it("should export encryption algorithm constants", () => {
198
+ expect(alg.KeyEncryptionAlgorithm.RSA_OAEP).toBe(
199
+ "http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p",
200
+ );
201
+ expect(alg.DataEncryptionAlgorithm.AES_256_GCM).toBe(
202
+ "http://www.w3.org/2009/xmlenc11#aes256-gcm",
203
+ );
204
+ });
205
+ });
@@ -0,0 +1,259 @@
1
+ import { APIError } from "better-auth/api";
2
+ import { XMLParser } from "fast-xml-parser";
3
+
4
+ export const SignatureAlgorithm = {
5
+ RSA_SHA1: "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
6
+ RSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
7
+ RSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384",
8
+ RSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512",
9
+ ECDSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256",
10
+ ECDSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384",
11
+ ECDSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512",
12
+ } as const;
13
+
14
+ export const DigestAlgorithm = {
15
+ SHA1: "http://www.w3.org/2000/09/xmldsig#sha1",
16
+ SHA256: "http://www.w3.org/2001/04/xmlenc#sha256",
17
+ SHA384: "http://www.w3.org/2001/04/xmldsig-more#sha384",
18
+ SHA512: "http://www.w3.org/2001/04/xmlenc#sha512",
19
+ } as const;
20
+
21
+ export const KeyEncryptionAlgorithm = {
22
+ RSA_1_5: "http://www.w3.org/2001/04/xmlenc#rsa-1_5",
23
+ RSA_OAEP: "http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p",
24
+ RSA_OAEP_SHA256: "http://www.w3.org/2009/xmlenc11#rsa-oaep",
25
+ } as const;
26
+
27
+ export const DataEncryptionAlgorithm = {
28
+ TRIPLEDES_CBC: "http://www.w3.org/2001/04/xmlenc#tripledes-cbc",
29
+ AES_128_CBC: "http://www.w3.org/2001/04/xmlenc#aes128-cbc",
30
+ AES_192_CBC: "http://www.w3.org/2001/04/xmlenc#aes192-cbc",
31
+ AES_256_CBC: "http://www.w3.org/2001/04/xmlenc#aes256-cbc",
32
+ AES_128_GCM: "http://www.w3.org/2009/xmlenc11#aes128-gcm",
33
+ AES_192_GCM: "http://www.w3.org/2009/xmlenc11#aes192-gcm",
34
+ AES_256_GCM: "http://www.w3.org/2009/xmlenc11#aes256-gcm",
35
+ } as const;
36
+
37
+ const DEPRECATED_SIGNATURE_ALGORITHMS: readonly string[] = [
38
+ SignatureAlgorithm.RSA_SHA1,
39
+ ];
40
+
41
+ const DEPRECATED_KEY_ENCRYPTION_ALGORITHMS: readonly string[] = [
42
+ KeyEncryptionAlgorithm.RSA_1_5,
43
+ ];
44
+
45
+ const DEPRECATED_DATA_ENCRYPTION_ALGORITHMS: readonly string[] = [
46
+ DataEncryptionAlgorithm.TRIPLEDES_CBC,
47
+ ];
48
+
49
+ const SECURE_SIGNATURE_ALGORITHMS: readonly string[] = [
50
+ SignatureAlgorithm.RSA_SHA256,
51
+ SignatureAlgorithm.RSA_SHA384,
52
+ SignatureAlgorithm.RSA_SHA512,
53
+ SignatureAlgorithm.ECDSA_SHA256,
54
+ SignatureAlgorithm.ECDSA_SHA384,
55
+ SignatureAlgorithm.ECDSA_SHA512,
56
+ ];
57
+
58
+ export type DeprecatedAlgorithmBehavior = "reject" | "warn" | "allow";
59
+
60
+ export interface AlgorithmValidationOptions {
61
+ onDeprecated?: DeprecatedAlgorithmBehavior;
62
+ allowedSignatureAlgorithms?: string[];
63
+ allowedDigestAlgorithms?: string[];
64
+ allowedKeyEncryptionAlgorithms?: string[];
65
+ allowedDataEncryptionAlgorithms?: string[];
66
+ }
67
+
68
+ const xmlParser = new XMLParser({
69
+ ignoreAttributes: false,
70
+ attributeNamePrefix: "@_",
71
+ removeNSPrefix: true,
72
+ });
73
+
74
+ function findNode(obj: unknown, nodeName: string): unknown {
75
+ if (!obj || typeof obj !== "object") return null;
76
+
77
+ const record = obj as Record<string, unknown>;
78
+
79
+ if (nodeName in record) {
80
+ return record[nodeName];
81
+ }
82
+
83
+ for (const value of Object.values(record)) {
84
+ if (Array.isArray(value)) {
85
+ for (const item of value) {
86
+ const found = findNode(item, nodeName);
87
+ if (found) return found;
88
+ }
89
+ } else if (typeof value === "object" && value !== null) {
90
+ const found = findNode(value, nodeName);
91
+ if (found) return found;
92
+ }
93
+ }
94
+
95
+ return null;
96
+ }
97
+
98
+ function extractEncryptionAlgorithms(xml: string): {
99
+ keyEncryption: string | null;
100
+ dataEncryption: string | null;
101
+ } {
102
+ try {
103
+ const parsed = xmlParser.parse(xml);
104
+
105
+ const encryptedKey = findNode(parsed, "EncryptedKey") as Record<
106
+ string,
107
+ unknown
108
+ > | null;
109
+ const keyEncMethod = encryptedKey?.EncryptionMethod as Record<
110
+ string,
111
+ unknown
112
+ > | null;
113
+ const keyAlg = keyEncMethod?.["@_Algorithm"] as string | undefined;
114
+
115
+ const encryptedData = findNode(parsed, "EncryptedData") as Record<
116
+ string,
117
+ unknown
118
+ > | null;
119
+ const dataEncMethod = encryptedData?.EncryptionMethod as Record<
120
+ string,
121
+ unknown
122
+ > | null;
123
+ const dataAlg = dataEncMethod?.["@_Algorithm"] as string | undefined;
124
+
125
+ return {
126
+ keyEncryption: keyAlg || null,
127
+ dataEncryption: dataAlg || null,
128
+ };
129
+ } catch {
130
+ return {
131
+ keyEncryption: null,
132
+ dataEncryption: null,
133
+ };
134
+ }
135
+ }
136
+
137
+ function hasEncryptedAssertion(xml: string): boolean {
138
+ try {
139
+ const parsed = xmlParser.parse(xml);
140
+ return findNode(parsed, "EncryptedAssertion") !== null;
141
+ } catch {
142
+ return false;
143
+ }
144
+ }
145
+
146
+ function handleDeprecatedAlgorithm(
147
+ message: string,
148
+ behavior: DeprecatedAlgorithmBehavior,
149
+ errorCode: string,
150
+ ): void {
151
+ switch (behavior) {
152
+ case "reject":
153
+ throw new APIError("BAD_REQUEST", {
154
+ message,
155
+ code: errorCode,
156
+ });
157
+ case "warn":
158
+ console.warn(`[SAML Security Warning] ${message}`);
159
+ break;
160
+ case "allow":
161
+ break;
162
+ }
163
+ }
164
+
165
+ function validateSignatureAlgorithm(
166
+ algorithm: string | null | undefined,
167
+ options: AlgorithmValidationOptions = {},
168
+ ): void {
169
+ if (!algorithm) {
170
+ return;
171
+ }
172
+
173
+ const { onDeprecated = "warn", allowedSignatureAlgorithms } = options;
174
+
175
+ if (allowedSignatureAlgorithms) {
176
+ if (!allowedSignatureAlgorithms.includes(algorithm)) {
177
+ throw new APIError("BAD_REQUEST", {
178
+ message: `SAML signature algorithm not in allow-list: ${algorithm}`,
179
+ code: "SAML_ALGORITHM_NOT_ALLOWED",
180
+ });
181
+ }
182
+ return;
183
+ }
184
+
185
+ if (DEPRECATED_SIGNATURE_ALGORITHMS.includes(algorithm)) {
186
+ handleDeprecatedAlgorithm(
187
+ `SAML response uses deprecated signature algorithm: ${algorithm}. Please configure your IdP to use SHA-256 or stronger.`,
188
+ onDeprecated,
189
+ "SAML_DEPRECATED_ALGORITHM",
190
+ );
191
+ return;
192
+ }
193
+
194
+ if (!SECURE_SIGNATURE_ALGORITHMS.includes(algorithm)) {
195
+ throw new APIError("BAD_REQUEST", {
196
+ message: `SAML signature algorithm not recognized: ${algorithm}`,
197
+ code: "SAML_UNKNOWN_ALGORITHM",
198
+ });
199
+ }
200
+ }
201
+
202
+ function validateEncryptionAlgorithms(
203
+ algorithms: { keyEncryption: string | null; dataEncryption: string | null },
204
+ options: AlgorithmValidationOptions = {},
205
+ ): void {
206
+ const {
207
+ onDeprecated = "warn",
208
+ allowedKeyEncryptionAlgorithms,
209
+ allowedDataEncryptionAlgorithms,
210
+ } = options;
211
+
212
+ const { keyEncryption, dataEncryption } = algorithms;
213
+
214
+ if (keyEncryption) {
215
+ if (allowedKeyEncryptionAlgorithms) {
216
+ if (!allowedKeyEncryptionAlgorithms.includes(keyEncryption)) {
217
+ throw new APIError("BAD_REQUEST", {
218
+ message: `SAML key encryption algorithm not in allow-list: ${keyEncryption}`,
219
+ code: "SAML_ALGORITHM_NOT_ALLOWED",
220
+ });
221
+ }
222
+ } else if (DEPRECATED_KEY_ENCRYPTION_ALGORITHMS.includes(keyEncryption)) {
223
+ handleDeprecatedAlgorithm(
224
+ `SAML response uses deprecated key encryption algorithm: ${keyEncryption}. Please configure your IdP to use RSA-OAEP.`,
225
+ onDeprecated,
226
+ "SAML_DEPRECATED_ALGORITHM",
227
+ );
228
+ }
229
+ }
230
+
231
+ if (dataEncryption) {
232
+ if (allowedDataEncryptionAlgorithms) {
233
+ if (!allowedDataEncryptionAlgorithms.includes(dataEncryption)) {
234
+ throw new APIError("BAD_REQUEST", {
235
+ message: `SAML data encryption algorithm not in allow-list: ${dataEncryption}`,
236
+ code: "SAML_ALGORITHM_NOT_ALLOWED",
237
+ });
238
+ }
239
+ } else if (DEPRECATED_DATA_ENCRYPTION_ALGORITHMS.includes(dataEncryption)) {
240
+ handleDeprecatedAlgorithm(
241
+ `SAML response uses deprecated data encryption algorithm: ${dataEncryption}. Please configure your IdP to use AES-GCM.`,
242
+ onDeprecated,
243
+ "SAML_DEPRECATED_ALGORITHM",
244
+ );
245
+ }
246
+ }
247
+ }
248
+
249
+ export function validateSAMLAlgorithms(
250
+ response: { sigAlg?: string | null; samlContent: string },
251
+ options?: AlgorithmValidationOptions,
252
+ ): void {
253
+ validateSignatureAlgorithm(response.sigAlg, options);
254
+
255
+ if (hasEncryptedAssertion(response.samlContent)) {
256
+ const encAlgs = extractEncryptionAlgorithms(response.samlContent);
257
+ validateEncryptionAlgorithms(encAlgs, options);
258
+ }
259
+ }
@@ -0,0 +1,9 @@
1
+ export {
2
+ type AlgorithmValidationOptions,
3
+ DataEncryptionAlgorithm,
4
+ type DeprecatedAlgorithmBehavior,
5
+ DigestAlgorithm,
6
+ KeyEncryptionAlgorithm,
7
+ SignatureAlgorithm,
8
+ validateSAMLAlgorithms,
9
+ } from "./algorithms";