@hammadj/better-auth-sso 1.5.0-beta.9
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 +116 -0
- package/LICENSE.md +20 -0
- package/dist/client.d.mts +10 -0
- package/dist/client.mjs +15 -0
- package/dist/client.mjs.map +1 -0
- package/dist/index.d.mts +738 -0
- package/dist/index.mjs +2953 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +87 -0
- package/src/client.ts +29 -0
- package/src/constants.ts +58 -0
- package/src/domain-verification.test.ts +551 -0
- package/src/index.ts +265 -0
- package/src/linking/index.ts +2 -0
- package/src/linking/org-assignment.test.ts +325 -0
- package/src/linking/org-assignment.ts +176 -0
- package/src/linking/types.ts +10 -0
- package/src/oidc/discovery.test.ts +1157 -0
- package/src/oidc/discovery.ts +494 -0
- package/src/oidc/errors.ts +92 -0
- package/src/oidc/index.ts +31 -0
- package/src/oidc/types.ts +219 -0
- package/src/oidc.test.ts +688 -0
- package/src/providers.test.ts +1326 -0
- package/src/routes/domain-verification.ts +275 -0
- package/src/routes/providers.ts +565 -0
- package/src/routes/schemas.ts +96 -0
- package/src/routes/sso.ts +2750 -0
- package/src/saml/algorithms.test.ts +449 -0
- package/src/saml/algorithms.ts +338 -0
- package/src/saml/assertions.test.ts +239 -0
- package/src/saml/assertions.ts +62 -0
- package/src/saml/index.ts +13 -0
- package/src/saml/parser.ts +56 -0
- package/src/saml-state.ts +78 -0
- package/src/saml.test.ts +4319 -0
- package/src/types.ts +365 -0
- package/src/utils.test.ts +103 -0
- package/src/utils.ts +81 -0
- package/tsconfig.json +14 -0
- package/tsdown.config.ts +9 -0
- package/vitest.config.ts +3 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { APIError } from "better-auth/api";
|
|
2
|
+
import { findNode, xmlParser } from "./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 DEPRECATED_DIGEST_ALGORITHMS: readonly string[] = [DigestAlgorithm.SHA1];
|
|
50
|
+
|
|
51
|
+
const SECURE_SIGNATURE_ALGORITHMS: readonly string[] = [
|
|
52
|
+
SignatureAlgorithm.RSA_SHA256,
|
|
53
|
+
SignatureAlgorithm.RSA_SHA384,
|
|
54
|
+
SignatureAlgorithm.RSA_SHA512,
|
|
55
|
+
SignatureAlgorithm.ECDSA_SHA256,
|
|
56
|
+
SignatureAlgorithm.ECDSA_SHA384,
|
|
57
|
+
SignatureAlgorithm.ECDSA_SHA512,
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
const SECURE_DIGEST_ALGORITHMS: readonly string[] = [
|
|
61
|
+
DigestAlgorithm.SHA256,
|
|
62
|
+
DigestAlgorithm.SHA384,
|
|
63
|
+
DigestAlgorithm.SHA512,
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
const SHORT_FORM_SIGNATURE_TO_URI: Record<string, string> = {
|
|
67
|
+
sha1: SignatureAlgorithm.RSA_SHA1,
|
|
68
|
+
sha256: SignatureAlgorithm.RSA_SHA256,
|
|
69
|
+
sha384: SignatureAlgorithm.RSA_SHA384,
|
|
70
|
+
sha512: SignatureAlgorithm.RSA_SHA512,
|
|
71
|
+
"rsa-sha1": SignatureAlgorithm.RSA_SHA1,
|
|
72
|
+
"rsa-sha256": SignatureAlgorithm.RSA_SHA256,
|
|
73
|
+
"rsa-sha384": SignatureAlgorithm.RSA_SHA384,
|
|
74
|
+
"rsa-sha512": SignatureAlgorithm.RSA_SHA512,
|
|
75
|
+
"ecdsa-sha256": SignatureAlgorithm.ECDSA_SHA256,
|
|
76
|
+
"ecdsa-sha384": SignatureAlgorithm.ECDSA_SHA384,
|
|
77
|
+
"ecdsa-sha512": SignatureAlgorithm.ECDSA_SHA512,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const SHORT_FORM_DIGEST_TO_URI: Record<string, string> = {
|
|
81
|
+
sha1: DigestAlgorithm.SHA1,
|
|
82
|
+
sha256: DigestAlgorithm.SHA256,
|
|
83
|
+
sha384: DigestAlgorithm.SHA384,
|
|
84
|
+
sha512: DigestAlgorithm.SHA512,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
function normalizeSignatureAlgorithm(alg: string): string {
|
|
88
|
+
return SHORT_FORM_SIGNATURE_TO_URI[alg.toLowerCase()] ?? alg;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function normalizeDigestAlgorithm(alg: string): string {
|
|
92
|
+
return SHORT_FORM_DIGEST_TO_URI[alg.toLowerCase()] ?? alg;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export type DeprecatedAlgorithmBehavior = "reject" | "warn" | "allow";
|
|
96
|
+
|
|
97
|
+
export interface AlgorithmValidationOptions {
|
|
98
|
+
onDeprecated?: DeprecatedAlgorithmBehavior;
|
|
99
|
+
allowedSignatureAlgorithms?: string[];
|
|
100
|
+
allowedDigestAlgorithms?: string[];
|
|
101
|
+
allowedKeyEncryptionAlgorithms?: string[];
|
|
102
|
+
allowedDataEncryptionAlgorithms?: string[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function extractEncryptionAlgorithms(xml: string): {
|
|
106
|
+
keyEncryption: string | null;
|
|
107
|
+
dataEncryption: string | null;
|
|
108
|
+
} {
|
|
109
|
+
try {
|
|
110
|
+
const parsed = xmlParser.parse(xml);
|
|
111
|
+
|
|
112
|
+
const encryptedKey = findNode(parsed, "EncryptedKey") as Record<
|
|
113
|
+
string,
|
|
114
|
+
unknown
|
|
115
|
+
> | null;
|
|
116
|
+
const keyEncMethod = encryptedKey?.EncryptionMethod as Record<
|
|
117
|
+
string,
|
|
118
|
+
unknown
|
|
119
|
+
> | null;
|
|
120
|
+
const keyAlg = keyEncMethod?.["@_Algorithm"] as string | undefined;
|
|
121
|
+
|
|
122
|
+
const encryptedData = findNode(parsed, "EncryptedData") as Record<
|
|
123
|
+
string,
|
|
124
|
+
unknown
|
|
125
|
+
> | null;
|
|
126
|
+
const dataEncMethod = encryptedData?.EncryptionMethod as Record<
|
|
127
|
+
string,
|
|
128
|
+
unknown
|
|
129
|
+
> | null;
|
|
130
|
+
const dataAlg = dataEncMethod?.["@_Algorithm"] as string | undefined;
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
keyEncryption: keyAlg || null,
|
|
134
|
+
dataEncryption: dataAlg || null,
|
|
135
|
+
};
|
|
136
|
+
} catch {
|
|
137
|
+
return {
|
|
138
|
+
keyEncryption: null,
|
|
139
|
+
dataEncryption: null,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function hasEncryptedAssertion(xml: string): boolean {
|
|
145
|
+
try {
|
|
146
|
+
const parsed = xmlParser.parse(xml);
|
|
147
|
+
return findNode(parsed, "EncryptedAssertion") !== null;
|
|
148
|
+
} catch {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function handleDeprecatedAlgorithm(
|
|
154
|
+
message: string,
|
|
155
|
+
behavior: DeprecatedAlgorithmBehavior,
|
|
156
|
+
errorCode: string,
|
|
157
|
+
): void {
|
|
158
|
+
switch (behavior) {
|
|
159
|
+
case "reject":
|
|
160
|
+
throw new APIError("BAD_REQUEST", {
|
|
161
|
+
message,
|
|
162
|
+
code: errorCode,
|
|
163
|
+
});
|
|
164
|
+
case "warn":
|
|
165
|
+
console.warn(`[SAML Security Warning] ${message}`);
|
|
166
|
+
break;
|
|
167
|
+
case "allow":
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function validateSignatureAlgorithm(
|
|
173
|
+
algorithm: string | null | undefined,
|
|
174
|
+
options: AlgorithmValidationOptions = {},
|
|
175
|
+
): void {
|
|
176
|
+
if (!algorithm) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const { onDeprecated = "warn", allowedSignatureAlgorithms } = options;
|
|
181
|
+
|
|
182
|
+
if (allowedSignatureAlgorithms) {
|
|
183
|
+
if (!allowedSignatureAlgorithms.includes(algorithm)) {
|
|
184
|
+
throw new APIError("BAD_REQUEST", {
|
|
185
|
+
message: `SAML signature algorithm not in allow-list: ${algorithm}`,
|
|
186
|
+
code: "SAML_ALGORITHM_NOT_ALLOWED",
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (DEPRECATED_SIGNATURE_ALGORITHMS.includes(algorithm)) {
|
|
193
|
+
handleDeprecatedAlgorithm(
|
|
194
|
+
`SAML response uses deprecated signature algorithm: ${algorithm}. Please configure your IdP to use SHA-256 or stronger.`,
|
|
195
|
+
onDeprecated,
|
|
196
|
+
"SAML_DEPRECATED_ALGORITHM",
|
|
197
|
+
);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (!SECURE_SIGNATURE_ALGORITHMS.includes(algorithm)) {
|
|
202
|
+
throw new APIError("BAD_REQUEST", {
|
|
203
|
+
message: `SAML signature algorithm not recognized: ${algorithm}`,
|
|
204
|
+
code: "SAML_UNKNOWN_ALGORITHM",
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function validateEncryptionAlgorithms(
|
|
210
|
+
algorithms: { keyEncryption: string | null; dataEncryption: string | null },
|
|
211
|
+
options: AlgorithmValidationOptions = {},
|
|
212
|
+
): void {
|
|
213
|
+
const {
|
|
214
|
+
onDeprecated = "warn",
|
|
215
|
+
allowedKeyEncryptionAlgorithms,
|
|
216
|
+
allowedDataEncryptionAlgorithms,
|
|
217
|
+
} = options;
|
|
218
|
+
|
|
219
|
+
const { keyEncryption, dataEncryption } = algorithms;
|
|
220
|
+
|
|
221
|
+
if (keyEncryption) {
|
|
222
|
+
if (allowedKeyEncryptionAlgorithms) {
|
|
223
|
+
if (!allowedKeyEncryptionAlgorithms.includes(keyEncryption)) {
|
|
224
|
+
throw new APIError("BAD_REQUEST", {
|
|
225
|
+
message: `SAML key encryption algorithm not in allow-list: ${keyEncryption}`,
|
|
226
|
+
code: "SAML_ALGORITHM_NOT_ALLOWED",
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
} else if (DEPRECATED_KEY_ENCRYPTION_ALGORITHMS.includes(keyEncryption)) {
|
|
230
|
+
handleDeprecatedAlgorithm(
|
|
231
|
+
`SAML response uses deprecated key encryption algorithm: ${keyEncryption}. Please configure your IdP to use RSA-OAEP.`,
|
|
232
|
+
onDeprecated,
|
|
233
|
+
"SAML_DEPRECATED_ALGORITHM",
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (dataEncryption) {
|
|
239
|
+
if (allowedDataEncryptionAlgorithms) {
|
|
240
|
+
if (!allowedDataEncryptionAlgorithms.includes(dataEncryption)) {
|
|
241
|
+
throw new APIError("BAD_REQUEST", {
|
|
242
|
+
message: `SAML data encryption algorithm not in allow-list: ${dataEncryption}`,
|
|
243
|
+
code: "SAML_ALGORITHM_NOT_ALLOWED",
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
} else if (DEPRECATED_DATA_ENCRYPTION_ALGORITHMS.includes(dataEncryption)) {
|
|
247
|
+
handleDeprecatedAlgorithm(
|
|
248
|
+
`SAML response uses deprecated data encryption algorithm: ${dataEncryption}. Please configure your IdP to use AES-GCM.`,
|
|
249
|
+
onDeprecated,
|
|
250
|
+
"SAML_DEPRECATED_ALGORITHM",
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function validateSAMLAlgorithms(
|
|
257
|
+
response: { sigAlg?: string | null; samlContent: string },
|
|
258
|
+
options?: AlgorithmValidationOptions,
|
|
259
|
+
): void {
|
|
260
|
+
validateSignatureAlgorithm(response.sigAlg, options);
|
|
261
|
+
|
|
262
|
+
if (hasEncryptedAssertion(response.samlContent)) {
|
|
263
|
+
const encAlgs = extractEncryptionAlgorithms(response.samlContent);
|
|
264
|
+
validateEncryptionAlgorithms(encAlgs, options);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export interface ConfigAlgorithmValidationOptions {
|
|
269
|
+
onDeprecated?: DeprecatedAlgorithmBehavior;
|
|
270
|
+
allowedSignatureAlgorithms?: string[];
|
|
271
|
+
allowedDigestAlgorithms?: string[];
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function validateConfigAlgorithms(
|
|
275
|
+
config: {
|
|
276
|
+
signatureAlgorithm?: string | undefined;
|
|
277
|
+
digestAlgorithm?: string | undefined;
|
|
278
|
+
},
|
|
279
|
+
options: ConfigAlgorithmValidationOptions = {},
|
|
280
|
+
): void {
|
|
281
|
+
const {
|
|
282
|
+
onDeprecated = "warn",
|
|
283
|
+
allowedSignatureAlgorithms,
|
|
284
|
+
allowedDigestAlgorithms,
|
|
285
|
+
} = options;
|
|
286
|
+
|
|
287
|
+
if (config.signatureAlgorithm) {
|
|
288
|
+
const normalized = normalizeSignatureAlgorithm(config.signatureAlgorithm);
|
|
289
|
+
if (allowedSignatureAlgorithms) {
|
|
290
|
+
const normalizedAllowList = allowedSignatureAlgorithms.map(
|
|
291
|
+
normalizeSignatureAlgorithm,
|
|
292
|
+
);
|
|
293
|
+
if (!normalizedAllowList.includes(normalized)) {
|
|
294
|
+
throw new APIError("BAD_REQUEST", {
|
|
295
|
+
message: `SAML signature algorithm not in allow-list: ${config.signatureAlgorithm}`,
|
|
296
|
+
code: "SAML_ALGORITHM_NOT_ALLOWED",
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
} else if (DEPRECATED_SIGNATURE_ALGORITHMS.includes(normalized)) {
|
|
300
|
+
handleDeprecatedAlgorithm(
|
|
301
|
+
`SAML config uses deprecated signature algorithm: ${config.signatureAlgorithm}. Consider using SHA-256 or stronger.`,
|
|
302
|
+
onDeprecated,
|
|
303
|
+
"SAML_DEPRECATED_CONFIG_ALGORITHM",
|
|
304
|
+
);
|
|
305
|
+
} else if (!SECURE_SIGNATURE_ALGORITHMS.includes(normalized)) {
|
|
306
|
+
throw new APIError("BAD_REQUEST", {
|
|
307
|
+
message: `SAML signature algorithm not recognized: ${config.signatureAlgorithm}`,
|
|
308
|
+
code: "SAML_UNKNOWN_ALGORITHM",
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (config.digestAlgorithm) {
|
|
314
|
+
const normalized = normalizeDigestAlgorithm(config.digestAlgorithm);
|
|
315
|
+
if (allowedDigestAlgorithms) {
|
|
316
|
+
const normalizedAllowList = allowedDigestAlgorithms.map(
|
|
317
|
+
normalizeDigestAlgorithm,
|
|
318
|
+
);
|
|
319
|
+
if (!normalizedAllowList.includes(normalized)) {
|
|
320
|
+
throw new APIError("BAD_REQUEST", {
|
|
321
|
+
message: `SAML digest algorithm not in allow-list: ${config.digestAlgorithm}`,
|
|
322
|
+
code: "SAML_ALGORITHM_NOT_ALLOWED",
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
} else if (DEPRECATED_DIGEST_ALGORITHMS.includes(normalized)) {
|
|
326
|
+
handleDeprecatedAlgorithm(
|
|
327
|
+
`SAML config uses deprecated digest algorithm: ${config.digestAlgorithm}. Consider using SHA-256 or stronger.`,
|
|
328
|
+
onDeprecated,
|
|
329
|
+
"SAML_DEPRECATED_CONFIG_ALGORITHM",
|
|
330
|
+
);
|
|
331
|
+
} else if (!SECURE_DIGEST_ALGORITHMS.includes(normalized)) {
|
|
332
|
+
throw new APIError("BAD_REQUEST", {
|
|
333
|
+
message: `SAML digest algorithm not recognized: ${config.digestAlgorithm}`,
|
|
334
|
+
code: "SAML_UNKNOWN_ALGORITHM",
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export {
|
|
2
|
+
type AlgorithmValidationOptions,
|
|
3
|
+
type ConfigAlgorithmValidationOptions,
|
|
4
|
+
DataEncryptionAlgorithm,
|
|
5
|
+
type DeprecatedAlgorithmBehavior,
|
|
6
|
+
DigestAlgorithm,
|
|
7
|
+
KeyEncryptionAlgorithm,
|
|
8
|
+
SignatureAlgorithm,
|
|
9
|
+
validateConfigAlgorithms,
|
|
10
|
+
validateSAMLAlgorithms,
|
|
11
|
+
} from "./algorithms";
|
|
12
|
+
|
|
13
|
+
export { validateSingleAssertion } from "./assertions";
|