samlesa 3.4.3 → 4.0.0
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/README.md +260 -25
- package/build/src/binding-artifact.js +150 -213
- package/build/src/entity-idp.js +2 -1
- package/build/src/entity-sp.js +19 -17
- package/build/src/flow.js +1 -8
- package/build/src/libsaml.js +10 -36
- package/build/src/schemaValidator.js +5 -7
- package/build/src/urn.js +93 -11
- package/build/src/utility.js +203 -3
- package/package.json +17 -4
- package/types/src/binding-artifact.d.ts +53 -17
- package/types/src/binding-artifact.d.ts.map +1 -1
- package/types/src/entity-idp.d.ts.map +1 -1
- package/types/src/entity-sp.d.ts +12 -14
- package/types/src/entity-sp.d.ts.map +1 -1
- package/types/src/flow.d.ts.map +1 -1
- package/types/src/libsaml.d.ts.map +1 -1
- package/types/src/schemaValidator.d.ts.map +1 -1
- package/types/src/urn.d.ts +53 -5
- package/types/src/urn.d.ts.map +1 -1
- package/types/src/utility.d.ts +35 -0
- package/types/src/utility.d.ts.map +1 -1
package/build/src/entity-sp.js
CHANGED
|
@@ -4,12 +4,11 @@
|
|
|
4
4
|
* @desc Declares the actions taken by service provider
|
|
5
5
|
*/
|
|
6
6
|
import Entity from './entity.js';
|
|
7
|
-
import
|
|
7
|
+
import artifactBinding from './binding-artifact.js';
|
|
8
8
|
import { namespace } from './urn.js';
|
|
9
9
|
import redirectBinding from './binding-redirect.js';
|
|
10
10
|
import postBinding from './binding-post.js';
|
|
11
11
|
import simpleSignBinding from './binding-simplesign.js';
|
|
12
|
-
import artifactSignBinding from './binding-artifact.js';
|
|
13
12
|
import { flow } from './flow.js';
|
|
14
13
|
/*
|
|
15
14
|
* @desc interface function
|
|
@@ -19,8 +18,7 @@ export default function (props) {
|
|
|
19
18
|
}
|
|
20
19
|
/**
|
|
21
20
|
* @desc Service provider can be configured using either metadata importing or spSetting
|
|
22
|
-
* @param {object}
|
|
23
|
-
|
|
21
|
+
* @param {object} spSetting
|
|
24
22
|
*/
|
|
25
23
|
export class ServiceProvider extends Entity {
|
|
26
24
|
/**
|
|
@@ -61,8 +59,13 @@ export class ServiceProvider extends Entity {
|
|
|
61
59
|
// Object context = {id, context, signature, sigAlg}
|
|
62
60
|
context = simpleSignBinding.base64LoginRequest({ idp, sp: this }, customTagReplacement);
|
|
63
61
|
break;
|
|
62
|
+
case nsBinding.artifact:
|
|
63
|
+
context = artifactBinding.soapLoginRequest("/*[local-name(.)='AuthnRequest']", {
|
|
64
|
+
idp,
|
|
65
|
+
sp: this
|
|
66
|
+
}, customTagReplacement);
|
|
67
|
+
break;
|
|
64
68
|
default:
|
|
65
|
-
// Will support artifact in the next release
|
|
66
69
|
throw new Error('ERR_SP_LOGIN_REQUEST_UNDEFINED_BINDING');
|
|
67
70
|
}
|
|
68
71
|
return {
|
|
@@ -73,13 +76,7 @@ export class ServiceProvider extends Entity {
|
|
|
73
76
|
};
|
|
74
77
|
}
|
|
75
78
|
async createLoginSoapRequest(idp, binding = 'artifact', config) {
|
|
76
|
-
const
|
|
77
|
-
const protocol = nsBinding[binding];
|
|
78
|
-
if (this.entityMeta.isAuthnRequestSigned() !== idp.entityMeta.isWantAuthnRequestsSigned()) {
|
|
79
|
-
throw new Error('ERR_METADATA_CONFLICT_REQUEST_SIGNED_FLAG');
|
|
80
|
-
}
|
|
81
|
-
let context = null;
|
|
82
|
-
context = await artifactSignBinding.soapLoginRequest("/*[local-name(.)='AuthnRequest']", {
|
|
79
|
+
const context = await artifactBinding.soapLoginRequest("/*[local-name(.)='AuthnRequest']", {
|
|
83
80
|
idp,
|
|
84
81
|
sp: this,
|
|
85
82
|
inResponse: config?.inResponseTo,
|
|
@@ -106,22 +103,27 @@ export class ServiceProvider extends Entity {
|
|
|
106
103
|
});
|
|
107
104
|
}
|
|
108
105
|
/**
|
|
109
|
-
* @desc
|
|
106
|
+
* @desc Parse and validate Artifact Resolve request
|
|
110
107
|
* @param {IdentityProvider} idp object of identity provider
|
|
111
|
-
* @param {string}
|
|
112
|
-
* @param {request} req request
|
|
108
|
+
* @param {string} xml SOAP request XML string
|
|
113
109
|
*/
|
|
114
110
|
parseLoginRequestResolve(idp, xml) {
|
|
115
111
|
const self = this;
|
|
116
|
-
return
|
|
112
|
+
return artifactBinding.parseLoginRequestResolve({
|
|
117
113
|
idp: idp,
|
|
118
114
|
sp: self,
|
|
119
115
|
xml: xml
|
|
120
116
|
});
|
|
121
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* @desc Resolve SAML Response by Artifact ID
|
|
120
|
+
* @param {IdentityProvider} idp object of identity provider
|
|
121
|
+
* @param {string} art Artifact string
|
|
122
|
+
* @param {request} req request
|
|
123
|
+
*/
|
|
122
124
|
parseLoginResponseResolve(idp, art, request) {
|
|
123
125
|
const self = this;
|
|
124
|
-
return
|
|
126
|
+
return artifactBinding.parseLoginResponseResolve({
|
|
125
127
|
idp: idp,
|
|
126
128
|
sp: self,
|
|
127
129
|
art: art
|
package/build/src/flow.js
CHANGED
|
@@ -225,7 +225,7 @@ async function postFlow(options) {
|
|
|
225
225
|
if (parserType === 'SAMLResponse'
|
|
226
226
|
&& extractedProperties.conditions
|
|
227
227
|
&& !verifyTime(extractedProperties.conditions.notBefore, extractedProperties.conditions.notOnOrAfter, self.entitySetting.clockDrifts)) {
|
|
228
|
-
return Promise.reject('
|
|
228
|
+
return Promise.reject('ERR_CONDITION_UNCONFIRMED');
|
|
229
229
|
}
|
|
230
230
|
// invalid subjectConfirmation time
|
|
231
231
|
// invalid time
|
|
@@ -416,13 +416,6 @@ async function postArtifactFlow(options) {
|
|
|
416
416
|
//There is no validation of the response here. The upper-layer application
|
|
417
417
|
// should verify the result by itself to see if the destination is equal to the SP acs and
|
|
418
418
|
// whether the response.id is used to prevent replay attacks.
|
|
419
|
-
let destination = extractedProperties?.response?.destination;
|
|
420
|
-
let isExit = self?.entityMeta?.meta?.assertionConsumerService?.filter((item) => {
|
|
421
|
-
return item?.location === destination;
|
|
422
|
-
});
|
|
423
|
-
if (isExit?.length === 0) {
|
|
424
|
-
return Promise.reject('ERR_Destination_URL');
|
|
425
|
-
}
|
|
426
419
|
if (parserType === 'SAMLResponse') {
|
|
427
420
|
let destination = extractedProperties?.response?.destination;
|
|
428
421
|
let isExit = self?.entityMeta?.meta?.assertionConsumerService?.filter((item) => {
|
package/build/src/libsaml.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { X509Certificate } from 'node:crypto';
|
|
7
7
|
import xml from 'xml';
|
|
8
|
-
import utility, {
|
|
8
|
+
import utility, { inflateString, isString, normalizeCertificates } from './utility.js';
|
|
9
9
|
;
|
|
10
10
|
import * as crypto from 'node:crypto';
|
|
11
11
|
import { algorithms, namespace, wording } from './urn.js';
|
|
@@ -524,13 +524,7 @@ xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="{ID}"
|
|
|
524
524
|
else if (opts.metadata) {
|
|
525
525
|
const certificateNode = select(".//*[local-name() = 'X509Certificate']", signatureNode);
|
|
526
526
|
let metadataCert = opts.metadata.getX509Certificate(certUse.signing);
|
|
527
|
-
|
|
528
|
-
metadataCert = flattenDeep(metadataCert);
|
|
529
|
-
}
|
|
530
|
-
else if (typeof metadataCert === 'string') {
|
|
531
|
-
metadataCert = [metadataCert];
|
|
532
|
-
}
|
|
533
|
-
metadataCert = metadataCert.map(utility.normalizeCerString);
|
|
527
|
+
metadataCert = normalizeCertificates(metadataCert);
|
|
534
528
|
if (certificateNode.length === 0 && metadataCert.length === 0) {
|
|
535
529
|
throw new Error('NO_SELECTED_CERTIFICATE');
|
|
536
530
|
}
|
|
@@ -580,14 +574,12 @@ xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="{ID}"
|
|
|
580
574
|
}
|
|
581
575
|
else if (opts.metadata) {
|
|
582
576
|
const certificateNode = select(".//*[local-name() = 'X509Certificate']", signatureNode);
|
|
577
|
+
console.log(opts.metadata.getX509Certificate);
|
|
578
|
+
console.log(certUse.signing);
|
|
579
|
+
console.log("执行情况");
|
|
580
|
+
console.log(opts);
|
|
583
581
|
let metadataCert = opts.metadata.getX509Certificate(certUse.signing);
|
|
584
|
-
|
|
585
|
-
metadataCert = flattenDeep(metadataCert);
|
|
586
|
-
}
|
|
587
|
-
else if (typeof metadataCert === 'string') {
|
|
588
|
-
metadataCert = [metadataCert];
|
|
589
|
-
}
|
|
590
|
-
metadataCert = metadataCert.map(utility.normalizeCerString);
|
|
582
|
+
metadataCert = normalizeCertificates(metadataCert);
|
|
591
583
|
if (certificateNode.length === 0 && metadataCert.length === 0) {
|
|
592
584
|
throw new Error('NO_SELECTED_CERTIFICATE');
|
|
593
585
|
}
|
|
@@ -627,13 +619,7 @@ xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="{ID}"
|
|
|
627
619
|
else if (opts.metadata) {
|
|
628
620
|
const certificateNode = select(".//*[local-name() = 'X509Certificate']", signatureNode);
|
|
629
621
|
let metadataCert = opts.metadata.getX509Certificate(certUse.signing);
|
|
630
|
-
|
|
631
|
-
metadataCert = flattenDeep(metadataCert);
|
|
632
|
-
}
|
|
633
|
-
else if (typeof metadataCert === 'string') {
|
|
634
|
-
metadataCert = [metadataCert];
|
|
635
|
-
}
|
|
636
|
-
metadataCert = metadataCert.map(utility.normalizeCerString);
|
|
622
|
+
metadataCert = normalizeCertificates(metadataCert);
|
|
637
623
|
if (certificateNode.length === 0 && metadataCert.length === 0) {
|
|
638
624
|
throw new Error('NO_SELECTED_CERTIFICATE');
|
|
639
625
|
}
|
|
@@ -791,13 +777,7 @@ xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="{ID}"
|
|
|
791
777
|
const certificateNode = select(".//*[local-name(.)='X509Certificate']", signatureNode);
|
|
792
778
|
// 证书处理逻辑
|
|
793
779
|
let metadataCert = opts.metadata.getX509Certificate(certUse.signing);
|
|
794
|
-
|
|
795
|
-
metadataCert = flattenDeep(metadataCert);
|
|
796
|
-
}
|
|
797
|
-
else if (typeof metadataCert === 'string') {
|
|
798
|
-
metadataCert = [metadataCert];
|
|
799
|
-
}
|
|
800
|
-
metadataCert = metadataCert.map(utility.normalizeCerString);
|
|
780
|
+
metadataCert = normalizeCertificates(metadataCert);
|
|
801
781
|
// 没有证书的情况
|
|
802
782
|
if (certificateNode.length === 0 && metadataCert.length === 0) {
|
|
803
783
|
throw new Error('NO_SELECTED_CERTIFICATE');
|
|
@@ -1070,13 +1050,7 @@ xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="{ID}"
|
|
|
1070
1050
|
else if (opts.metadata) {
|
|
1071
1051
|
const certificateNode = select(".//*[local-name(.)='X509Certificate']", signatureNode);
|
|
1072
1052
|
let metadataCert = opts.metadata.getX509Certificate(certUse.signing);
|
|
1073
|
-
|
|
1074
|
-
metadataCert = flattenDeep(metadataCert);
|
|
1075
|
-
}
|
|
1076
|
-
else if (typeof metadataCert === 'string') {
|
|
1077
|
-
metadataCert = [metadataCert];
|
|
1078
|
-
}
|
|
1079
|
-
metadataCert = metadataCert.map(utility.normalizeCerString);
|
|
1053
|
+
metadataCert = normalizeCertificates(metadataCert);
|
|
1080
1054
|
if (certificateNode.length === 0 && metadataCert.length === 0) {
|
|
1081
1055
|
throw new Error('NO_SELECTED_CERTIFICATE');
|
|
1082
1056
|
}
|
|
@@ -43,13 +43,11 @@ const metadataSchemas = [
|
|
|
43
43
|
*/
|
|
44
44
|
function detectXXEIndicators(samlString) {
|
|
45
45
|
const xxePatterns = [
|
|
46
|
-
/<!DOCTYPE\s[^>]*>/i,
|
|
47
|
-
/<!ENTITY\s+[^\s>]+\s+(?:SYSTEM|PUBLIC)\s+['"][^>]*>/i,
|
|
48
|
-
|
|
49
|
-
/SYSTEM\s
|
|
50
|
-
/PUBLIC\s
|
|
51
|
-
/file:\/\//,
|
|
52
|
-
/\.dtd['"]?/
|
|
46
|
+
/<!DOCTYPE\s[^>]*>/i, // DOCTYPE 声明
|
|
47
|
+
/<!ENTITY\s+[^\s>]+\s+(?:SYSTEM|PUBLIC)\s+['"][^>]*>/i, // 外部实体声明
|
|
48
|
+
/SYSTEM\s*['"]\s*file:\/\//i, // file:// 协议的系统引用
|
|
49
|
+
/SYSTEM\s*['"]\s*\.\.\/.*\.dtd['"]?/i, // 相对路径的 DTD 引用
|
|
50
|
+
/PUBLIC\s*['"][^'"]*['"]\s*['"][^'"]*\.dtd['"]?/i // 公共 DTD 引用
|
|
53
51
|
];
|
|
54
52
|
const matches = {};
|
|
55
53
|
xxePatterns.forEach((pattern, index) => {
|
package/build/src/urn.js
CHANGED
|
@@ -189,22 +189,31 @@ const messageConfigurations = {
|
|
|
189
189
|
const algorithms = {
|
|
190
190
|
// 1. 签名算法定义 (SignatureMethod)
|
|
191
191
|
signature: {
|
|
192
|
-
// ❌
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
DSA_SHA1: 'http://www.w3.org/2000/09/xmldsig#dsa-sha1',
|
|
197
|
-
RSA_SHA1: 'http://www.w3.org/2000/09/xmldsig#rsa-sha1',
|
|
192
|
+
// ❌ 不安全的算法(已废弃)
|
|
193
|
+
RSA_SHA1: 'http://www.w3.org/2000/09/xmldsig#rsa-sha1', // ⚠️ 已废弃,不推荐使用
|
|
194
|
+
DSA_SHA1: 'http://www.w3.org/2000/09/xmldsig#dsa-sha1', // ⚠️ 已废弃,不推荐使用
|
|
195
|
+
// ✅ 安全的 RSA 算法(推荐)
|
|
198
196
|
RSA_SHA224: 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha224',
|
|
199
|
-
RSA_SHA256: 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256', // 推荐
|
|
197
|
+
RSA_SHA256: 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256', // ⭐ 推荐
|
|
200
198
|
RSA_SHA384: 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha384',
|
|
201
199
|
RSA_SHA512: 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha512',
|
|
202
|
-
//
|
|
200
|
+
// ✅ ECDSA 算法(推荐)
|
|
201
|
+
ECDSA_SHA256: 'http://www.w3.org/2007/05/xmldsig-more#ecdsa-sha256', // ⭐ 推荐
|
|
202
|
+
ECDSA_SHA384: 'http://www.w3.org/2007/05/xmldsig-more#ecdsa-sha384',
|
|
203
|
+
ECDSA_SHA512: 'http://www.w3.org/2007/05/xmldsig-more#ecdsa-sha512',
|
|
204
|
+
// ✅ XML Signature 1.1 PSS 填充(更安全)
|
|
203
205
|
RSA_PSS_SHA256: 'http://www.w3.org/2007/05/xmldsig-more#rsa-pss-sha256',
|
|
204
|
-
// EdDSA (Ed25519)
|
|
205
|
-
EDDSA_ED25519: 'http://www.w3.org/2007/05/xmldsig-more#eddsa-ed25519',
|
|
206
|
+
// ✅ EdDSA (Ed25519/Ed448)
|
|
207
|
+
EDDSA_ED25519: 'http://www.w3.org/2007/05/xmldsig-more#eddsa-ed25519', // ⭐ 推荐
|
|
206
208
|
EDDSA_ED488: 'http://www.w3.org/2021/04/xmldsig-more#eddsa-ed448'
|
|
207
209
|
},
|
|
210
|
+
// 不安全的算法列表(用于验证和阻止)
|
|
211
|
+
unsafeAlgorithms: [
|
|
212
|
+
'http://www.w3.org/2000/09/xmldsig#rsa-sha1',
|
|
213
|
+
'http://www.w3.org/2000/09/xmldsig#dsa-sha1',
|
|
214
|
+
'http://www.w3.org/2000/09/xmldsig#hmac-sha1',
|
|
215
|
+
'http://www.w3.org/2000/09/xmldsig#sha1',
|
|
216
|
+
],
|
|
208
217
|
// 2. 摘要算法定义 (DigestMethod)
|
|
209
218
|
// 注意:这里直接使用标准推荐的 URI,SHA-2xx 系列推荐使用 xmlenc 命名空间
|
|
210
219
|
digest: {
|
|
@@ -322,4 +331,77 @@ const elementsOrder = {
|
|
|
322
331
|
onelogin: ['KeyDescriptor', 'NameIDFormat', 'ArtifactResolutionService', 'SingleLogoutService', 'AssertionConsumerService', 'AttributeConsumingService'],
|
|
323
332
|
shibboleth: ['KeyDescriptor', 'ArtifactResolutionService', 'SingleLogoutService', 'NameIDFormat', 'AssertionConsumerService', 'AttributeConsumingService',],
|
|
324
333
|
};
|
|
325
|
-
|
|
334
|
+
/**
|
|
335
|
+
* 默认安全配置
|
|
336
|
+
*/
|
|
337
|
+
const defaultSecurityOptions = {
|
|
338
|
+
allowSHA1: false,
|
|
339
|
+
allowRSA15: false,
|
|
340
|
+
allowTripleDES: false,
|
|
341
|
+
};
|
|
342
|
+
/**
|
|
343
|
+
* 当前安全配置
|
|
344
|
+
*/
|
|
345
|
+
let currentSecurityOptions = { ...defaultSecurityOptions };
|
|
346
|
+
/**
|
|
347
|
+
* 设置安全配置
|
|
348
|
+
* @param options 安全配置选项
|
|
349
|
+
*/
|
|
350
|
+
function setSecurityOptions(options) {
|
|
351
|
+
currentSecurityOptions = { ...currentSecurityOptions, ...options };
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* 获取当前安全配置
|
|
355
|
+
* @returns 安全配置对象
|
|
356
|
+
*/
|
|
357
|
+
function getSecurityOptions() {
|
|
358
|
+
return currentSecurityOptions;
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* 重置为默认安全配置
|
|
362
|
+
*/
|
|
363
|
+
function resetSecurityOptions() {
|
|
364
|
+
currentSecurityOptions = { ...defaultSecurityOptions };
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* 验证算法是否安全
|
|
368
|
+
* @param algorithm 算法 URI
|
|
369
|
+
* @returns 验证结果
|
|
370
|
+
*/
|
|
371
|
+
function validateAlgorithm(algorithm) {
|
|
372
|
+
// 检查 SHA-1
|
|
373
|
+
if (!currentSecurityOptions.allowSHA1 && algorithm.toLowerCase().includes('sha1')) {
|
|
374
|
+
return {
|
|
375
|
+
valid: false,
|
|
376
|
+
reason: 'SHA-1 algorithm is not allowed. Use SHA-256 or stronger.'
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
// 检查 RSA-1_5
|
|
380
|
+
if (!currentSecurityOptions.allowRSA15 && algorithm.includes('rsa-1_5')) {
|
|
381
|
+
return {
|
|
382
|
+
valid: false,
|
|
383
|
+
reason: 'RSA-1_5 key encryption is not allowed. Use RSA-OAEP instead.'
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
// 检查 TripleDES
|
|
387
|
+
if (!currentSecurityOptions.allowTripleDES && algorithm.includes('tripledes')) {
|
|
388
|
+
return {
|
|
389
|
+
valid: false,
|
|
390
|
+
reason: 'TripleDES encryption is not allowed. Use AES-GCM instead.'
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
return { valid: true };
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* 检查算法是否为不安全算法
|
|
397
|
+
* @param algorithm 算法 URI
|
|
398
|
+
* @returns 检查结果
|
|
399
|
+
*/
|
|
400
|
+
function checkUnsafeAlgorithm(algorithm) {
|
|
401
|
+
const isUnsafe = algorithms.unsafeAlgorithms.some(unsafeAlg => algorithm.toLowerCase().includes(unsafeAlg.toLowerCase().replace('http://www.w3.org/2000/09/xmldsig#', '').replace('#', ''))) || algorithm.toLowerCase().includes('sha1');
|
|
402
|
+
return {
|
|
403
|
+
isUnsafe,
|
|
404
|
+
algorithm: isUnsafe ? algorithm : undefined
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
export { namespace, tags, algorithms, wording, elementsOrder, messageConfigurations, getBindingName, defaultSecurityOptions, setSecurityOptions, getSecurityOptions, resetSecurityOptions, validateAlgorithm, checkUnsafeAlgorithm };
|
package/build/src/utility.js
CHANGED
|
@@ -178,11 +178,21 @@ function applyDefault(obj1, obj2) {
|
|
|
178
178
|
* @return {string} public key fetched from the certificate
|
|
179
179
|
*/
|
|
180
180
|
function getPublicKeyPemFromCertificate(x509CertificateString) {
|
|
181
|
-
|
|
181
|
+
// 清理证书字符串:移除 PEM 头尾、换行符、空格等
|
|
182
|
+
const cleanCert = x509CertificateString
|
|
183
|
+
.replace(/-----BEGIN CERTIFICATE-----/g, '')
|
|
184
|
+
.replace(/-----END CERTIFICATE-----/g, '')
|
|
185
|
+
.replace(/\r\n/g, '')
|
|
186
|
+
.replace(/\n/g, '')
|
|
187
|
+
.replace(/\r/g, '')
|
|
188
|
+
.replace(/ /g, '')
|
|
189
|
+
.trim();
|
|
190
|
+
// 将 Base64 字符串转换为 PEM 格式(添加头尾标记)
|
|
191
|
+
const pemCert = `-----BEGIN CERTIFICATE-----\n${cleanCert}\n-----END CERTIFICATE-----`;
|
|
182
192
|
// 解析 X.509 证书
|
|
183
|
-
const cert2 = new X509Certificate(
|
|
193
|
+
const cert2 = new X509Certificate(pemCert);
|
|
184
194
|
const publicKeyObject = cert2.publicKey;
|
|
185
|
-
//
|
|
195
|
+
// 导出为 PEM 格式
|
|
186
196
|
return publicKeyObject.export({
|
|
187
197
|
type: 'spki', // 使用 Subject Public Key Info 结构
|
|
188
198
|
format: 'pem' // 输出 PEM 格式
|
|
@@ -312,6 +322,192 @@ export function castArrayOpt(a) {
|
|
|
312
322
|
export function notEmpty(value) {
|
|
313
323
|
return value !== null && value !== undefined;
|
|
314
324
|
}
|
|
325
|
+
/**
|
|
326
|
+
* @desc 验证 RelayState 是否符合 SAML 2.0 规范
|
|
327
|
+
* @param {string} relayState - RelayState 值
|
|
328
|
+
* @returns {{ valid: boolean; error?: string }} 验证结果
|
|
329
|
+
*/
|
|
330
|
+
export function validateRelayState(relayState) {
|
|
331
|
+
// RelayState 是可选的
|
|
332
|
+
if (!relayState || relayState.length === 0) {
|
|
333
|
+
return { valid: true };
|
|
334
|
+
}
|
|
335
|
+
// 验证长度(SAML 规范限制 80 字节)
|
|
336
|
+
if (relayState.length > 80) {
|
|
337
|
+
return {
|
|
338
|
+
valid: false,
|
|
339
|
+
error: 'RelayState exceeds 80 bytes'
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
// 验证是否为合法 URL(如果是 URL)
|
|
343
|
+
if (relayState.startsWith('http://') || relayState.startsWith('https://')) {
|
|
344
|
+
try {
|
|
345
|
+
new URL(relayState);
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
return {
|
|
349
|
+
valid: false,
|
|
350
|
+
error: 'RelayState is not a valid URL'
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return { valid: true };
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* @desc 敏感信息键名列表(用于日志脱敏)
|
|
358
|
+
*/
|
|
359
|
+
const sensitiveKeys = [
|
|
360
|
+
'privateKey',
|
|
361
|
+
'privateKeyPass',
|
|
362
|
+
'encPrivateKey',
|
|
363
|
+
'encPrivateKeyPass',
|
|
364
|
+
'password',
|
|
365
|
+
'secret',
|
|
366
|
+
'signingCert',
|
|
367
|
+
'encryptCert'
|
|
368
|
+
];
|
|
369
|
+
/**
|
|
370
|
+
* @desc 验证并标准化证书数组,处理 null、undefined、空字符串、非数组等边界情况
|
|
371
|
+
* @param {any} metadataCert - 证书输入,可能是 string、string[]、null、undefined 或其他类型
|
|
372
|
+
* @returns {string[]} 标准化后的证书字符串数组(纯 Base64 格式,无 PEM 头尾和换行)
|
|
373
|
+
* @throws {Error} 当证书格式无效时抛出错误
|
|
374
|
+
*/
|
|
375
|
+
export function normalizeCertificates(metadataCert) {
|
|
376
|
+
// 处理 null 或 undefined
|
|
377
|
+
if (metadataCert === null || metadataCert === undefined) {
|
|
378
|
+
return [];
|
|
379
|
+
}
|
|
380
|
+
let certArray;
|
|
381
|
+
// 转换为数组
|
|
382
|
+
if (Array.isArray(metadataCert)) {
|
|
383
|
+
// 扁平化嵌套数组
|
|
384
|
+
certArray = flattenDeep(metadataCert);
|
|
385
|
+
}
|
|
386
|
+
else if (typeof metadataCert === 'string') {
|
|
387
|
+
// 单个字符串转为数组
|
|
388
|
+
certArray = [metadataCert];
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
// 不支持的类型
|
|
392
|
+
console.warn(`normalizeCertificates: 不支持的证书类型 ${typeof metadataCert},返回空数组`);
|
|
393
|
+
return [];
|
|
394
|
+
}
|
|
395
|
+
// 过滤和清理证书
|
|
396
|
+
const cleanedCerts = certArray
|
|
397
|
+
.filter((cert) => {
|
|
398
|
+
// 过滤 null、undefined、空字符串
|
|
399
|
+
if (cert === null || cert === undefined) {
|
|
400
|
+
return false;
|
|
401
|
+
}
|
|
402
|
+
if (typeof cert !== 'string') {
|
|
403
|
+
console.warn(`normalizeCertificates: 跳过非字符串证书类型 ${typeof cert}`);
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
const trimmed = cert.trim();
|
|
407
|
+
if (trimmed.length === 0) {
|
|
408
|
+
return false;
|
|
409
|
+
}
|
|
410
|
+
return true;
|
|
411
|
+
})
|
|
412
|
+
.map((cert) => {
|
|
413
|
+
// 清理证书字符串:移除 PEM 头尾、换行符、空格等
|
|
414
|
+
return cert
|
|
415
|
+
.replace(/-----BEGIN CERTIFICATE-----/g, '')
|
|
416
|
+
.replace(/-----END CERTIFICATE-----/g, '')
|
|
417
|
+
.replace(/\r\n/g, '')
|
|
418
|
+
.replace(/\n/g, '')
|
|
419
|
+
.replace(/\r/g, '')
|
|
420
|
+
.replace(/ /g, '')
|
|
421
|
+
.trim();
|
|
422
|
+
})
|
|
423
|
+
.filter((cert) => cert.length > 0); // 再次过滤空字符串
|
|
424
|
+
// 验证证书格式(可选,仅验证 Base64 格式)
|
|
425
|
+
const base64Regex = /^[A-Za-z0-9+/]+=*$/;
|
|
426
|
+
for (const cert of cleanedCerts) {
|
|
427
|
+
if (!base64Regex.test(cert)) {
|
|
428
|
+
throw new Error(`无效的证书格式:证书必须是有效的 Base64 编码,当前值:${cert.substring(0, 50)}...`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return cleanedCerts;
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* @desc 验证证书是否有效(可选,用于更严格的验证)
|
|
435
|
+
* @param {string} certificateBase64 - Base64 编码的证书(不含 PEM 头尾)
|
|
436
|
+
* @returns {{ isValid: boolean; error?: string }} 验证结果
|
|
437
|
+
*/
|
|
438
|
+
export function validateCertificate(certificateBase64) {
|
|
439
|
+
try {
|
|
440
|
+
// 清理证书
|
|
441
|
+
const cleanCert = certificateBase64
|
|
442
|
+
.replace(/-----BEGIN CERTIFICATE-----/g, '')
|
|
443
|
+
.replace(/-----END CERTIFICATE-----/g, '')
|
|
444
|
+
.replace(/\r\n/g, '')
|
|
445
|
+
.replace(/\n/g, '')
|
|
446
|
+
.replace(/\r/g, '')
|
|
447
|
+
.replace(/ /g, '')
|
|
448
|
+
.trim();
|
|
449
|
+
// 验证 Base64 格式
|
|
450
|
+
const base64Regex = /^[A-Za-z0-9+/]+=*$/;
|
|
451
|
+
if (!base64Regex.test(cleanCert)) {
|
|
452
|
+
return {
|
|
453
|
+
isValid: false,
|
|
454
|
+
error: '无效的 Base64 编码'
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
// 转换为 PEM 格式
|
|
458
|
+
const pemCert = `-----BEGIN CERTIFICATE-----\n${cleanCert}\n-----END CERTIFICATE-----`;
|
|
459
|
+
// 尝试解析证书
|
|
460
|
+
const cert = new X509Certificate(pemCert);
|
|
461
|
+
// 检查有效期
|
|
462
|
+
const now = new Date();
|
|
463
|
+
if (new Date(cert.validFrom) > now || new Date(cert.validTo) < now) {
|
|
464
|
+
return {
|
|
465
|
+
isValid: false,
|
|
466
|
+
error: '证书已过期或尚未生效'
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
// 检查公钥类型
|
|
470
|
+
const keyType = cert.publicKey.asymmetricKeyType;
|
|
471
|
+
if (keyType && !['rsa', 'ec'].includes(keyType)) {
|
|
472
|
+
return {
|
|
473
|
+
isValid: false,
|
|
474
|
+
error: '证书使用不支持的公钥类型'
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
return { isValid: true };
|
|
478
|
+
}
|
|
479
|
+
catch (error) {
|
|
480
|
+
return {
|
|
481
|
+
isValid: false,
|
|
482
|
+
error: error instanceof Error ? error.message : '未知错误'
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* @desc 日志脱敏函数,过滤敏感信息
|
|
488
|
+
* @param {any} data - 需要脱敏的数据
|
|
489
|
+
* @returns {any} 脱敏后的数据
|
|
490
|
+
*/
|
|
491
|
+
export function sanitizeLog(data) {
|
|
492
|
+
if (typeof data !== 'object' || data === null) {
|
|
493
|
+
return data;
|
|
494
|
+
}
|
|
495
|
+
const sanitized = Array.isArray(data) ? [] : {};
|
|
496
|
+
for (const [key, value] of Object.entries(data)) {
|
|
497
|
+
// 检查是否为敏感键名
|
|
498
|
+
if (sensitiveKeys.some(k => k.toLowerCase() === key.toLowerCase())) {
|
|
499
|
+
sanitized[key] = '***REDACTED***';
|
|
500
|
+
}
|
|
501
|
+
else if (typeof value === 'object' && value !== null) {
|
|
502
|
+
// 递归处理嵌套对象
|
|
503
|
+
sanitized[key] = sanitizeLog(value);
|
|
504
|
+
}
|
|
505
|
+
else {
|
|
506
|
+
sanitized[key] = value;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
return sanitized;
|
|
510
|
+
}
|
|
315
511
|
const utility = {
|
|
316
512
|
isString,
|
|
317
513
|
base64Encode,
|
|
@@ -327,5 +523,9 @@ const utility = {
|
|
|
327
523
|
readPrivateKey,
|
|
328
524
|
convertToString,
|
|
329
525
|
isNonEmptyArray,
|
|
526
|
+
validateRelayState,
|
|
527
|
+
sanitizeLog,
|
|
528
|
+
normalizeCertificates,
|
|
529
|
+
validateCertificate,
|
|
330
530
|
};
|
|
331
531
|
export default utility;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "samlesa",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0",
|
|
4
4
|
"description": "High-level API for Single Sign On (SAML 2.0) baseed on samlify ",
|
|
5
5
|
"main": "build/index.js",
|
|
6
6
|
"keywords": [
|
|
@@ -12,14 +12,23 @@
|
|
|
12
12
|
],
|
|
13
13
|
"type": "module",
|
|
14
14
|
"typings": "types/index.d.ts",
|
|
15
|
+
"homepage": "https://saml.veclea.com",
|
|
15
16
|
"scripts": {
|
|
16
17
|
"build": "tsc && copyfiles -u 1 src/schema/**/* build/src",
|
|
17
|
-
"
|
|
18
|
+
"build:fast": "tsc",
|
|
19
|
+
"build:clean": "tsc --build --clean && pnpm run build",
|
|
20
|
+
"docs:dev": "cd docs && npm run docs:dev",
|
|
21
|
+
"docs:build": "cd docs && npm run docs:build",
|
|
22
|
+
"docs:preview": "cd docs && npm run docs:preview",
|
|
23
|
+
"docs:deploy": "vercel --prod",
|
|
18
24
|
"lint": "tslint -p .",
|
|
19
25
|
"lint:fix": "tslint -p . --fix",
|
|
20
|
-
"test": "vitest",
|
|
21
|
-
"test:watch": "vitest
|
|
26
|
+
"test": "vitest run",
|
|
27
|
+
"test:watch": "vitest",
|
|
22
28
|
"test:coverage": "vitest run --coverage",
|
|
29
|
+
"test:fast": "vitest run --pool=forks",
|
|
30
|
+
"test:artifact": "vitest run test/artifact.test.ts",
|
|
31
|
+
"generate-certs": "node scripts/generate-certs.js",
|
|
23
32
|
"hooks:postinstall": "mklink /J .git\\hooks\\pre-commit .pre-commit.sh || copy .pre-commit.sh .git\\hooks\\pre-commit"
|
|
24
33
|
},
|
|
25
34
|
"exports": {
|
|
@@ -40,6 +49,10 @@
|
|
|
40
49
|
"url": "https://github.com/Veclea/samlify.git",
|
|
41
50
|
"type": "git"
|
|
42
51
|
},
|
|
52
|
+
"bugs": {
|
|
53
|
+
"url": "https://github.com/Veclea/samlify/issues"
|
|
54
|
+
},
|
|
55
|
+
"docs": "https://saml.veclea.com",
|
|
43
56
|
"license": "MIT",
|
|
44
57
|
"dependencies": {
|
|
45
58
|
"@xmldom/xmldom": "^0.9.8",
|