samlesa 3.4.1 → 3.4.3

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.
@@ -5,11 +5,13 @@
5
5
  */
6
6
  import { checkStatus } from "./flow.js";
7
7
  import { ParserType, StatusCode, wording } from './urn.js';
8
+ import * as crypto from "node:crypto";
8
9
  import libsaml from './libsaml.js';
9
10
  import libsamlSoap from './libsamlSoap.js';
10
11
  import utility, { get } from './utility.js';
11
12
  import { fileURLToPath } from "node:url";
12
13
  import { randomUUID } from 'node:crypto';
14
+ import postBinding from './binding-post.js';
13
15
  import { artifactResolveFields, extract, loginRequestFields, loginResponseFields, logoutRequestFields, logoutResponseFields } from "./extractor.js";
14
16
  import { verifyTime } from "./validator.js";
15
17
  import { sendArtifactResolve } from "./soap.js";
@@ -137,141 +139,111 @@ function soapLoginRequest(referenceTagXPath, entity, customTagReplacement) {
137
139
  throw new Error('ERR_GENERATE_POST_LOGIN_REQUEST_MISSING_METADATA');
138
140
  }
139
141
  /**
140
- * @desc Generate a base64 encoded login response
141
- * @param {object} requestInfo corresponding request, used to obtain the id
142
- * @param {object} entity object includes both idp and sp
143
- * @param {object} user current logged user (e.g. req.user)
144
- * @param {function} customTagReplacement used when developers have their own login response template
145
- * @param {boolean} encryptThenSign whether or not to encrypt then sign first (if signing). Defaults to sign-then-encrypt
146
- * @param AttributeStatement
142
+ * Generates a SAML 2.0 compliant Artifact ID.
143
+ * Format: [4-byte source ID][2-byte endpoint index][20-byte sequence value]
144
+ * @param issuerId The entity ID of the issuing party (IdP).
145
+ * @param endpointIndex The index of the destination endpoint (default is '0004' for Artifact Resolution Service).
146
+ * @returns The Base64 encoded Artifact ID string.
147
147
  */
148
- async function soapLoginResponse(requestInfo = {}, entity, user = {}, customTagReplacement, encryptThenSign = false, AttributeStatement = []) {
149
- const idpSetting = entity.idp.entitySetting;
150
- const spSetting = entity.sp.entitySetting;
151
- const id = idpSetting.generateID();
148
+ /**
149
+ * 生成符合 SAML 2.0 规范的 Artifact
150
+ * 结构: [TypeCode: 2 bytes] + [EndpointIndex: 2 bytes] + [SourceID: 20 bytes] + [MessageHandle: 20 bytes]
151
+ */
152
+ function generateArtifactId(issuerId, endpointIndex = 1) {
153
+ // 1. SourceID: 20 bytes SHA-1
154
+ const issuerHash = crypto.createHash('sha1').update(issuerId).digest();
155
+ const sourceId = issuerHash.subarray(0, 20); // 必须 20 字节
156
+ // 2. TypeCode: 0x0004
157
+ const typeCode = Buffer.from([0x00, 0x04]);
158
+ // 3. EndpointIndex: 2 bytes Big Endian
159
+ const indexBuffer = Buffer.alloc(2);
160
+ indexBuffer.writeUInt16BE(endpointIndex, 0);
161
+ // 4. MessageHandle: 20 random bytes
162
+ const messageHandle = crypto.randomBytes(20);
163
+ // 5. Concat: 2 + 2 + 20 + 20 = 44 bytes
164
+ const artifactBytes = Buffer.concat([typeCode, indexBuffer, sourceId, messageHandle]);
165
+ // 6. Base64
166
+ return artifactBytes.toString('base64');
167
+ }
168
+ // Initial response that sends only the artifact ID
169
+ async function soapLoginResponse(params) {
170
+ const { entity } = params;
152
171
  const metadata = {
153
172
  idp: entity.idp.entityMeta,
154
173
  sp: entity.sp.entityMeta,
155
174
  };
156
- const nameIDFormat = idpSetting.nameIDFormat;
157
- const selectedNameIDFormat = Array.isArray(nameIDFormat) ? nameIDFormat[0] : nameIDFormat;
158
- if (metadata && metadata.idp && metadata.sp) {
159
- const base = metadata.sp.getAssertionConsumerService(binding.post);
160
- let rawSamlResponse;
161
- const nowTime = new Date();
162
- const spEntityID = metadata.sp.getEntityID();
163
- const oneMinutesLaterTime = new Date(nowTime.getTime());
164
- oneMinutesLaterTime.setMinutes(oneMinutesLaterTime.getMinutes() + 5);
165
- const OneMinutesLater = oneMinutesLaterTime.toISOString();
166
- const now = nowTime.toISOString();
167
- const acl = metadata.sp.getAssertionConsumerService(binding.post);
168
- const sessionIndex = 'session' + idpSetting.generateID(); // 这个是当前系统的会话索引,用于单点注销
169
- const tenHoursLaterTime = new Date(nowTime.getTime());
170
- tenHoursLaterTime.setHours(tenHoursLaterTime.getHours() + 10);
171
- const tenHoursLater = tenHoursLaterTime.toISOString();
172
- const tvalue = {
173
- ID: id,
174
- AssertionID: idpSetting.generateID(),
175
- Destination: base,
176
- Audience: spEntityID,
177
- EntityID: spEntityID,
178
- SubjectRecipient: acl,
179
- Issuer: metadata.idp.getEntityID(),
180
- IssueInstant: now,
181
- AssertionConsumerServiceURL: acl,
182
- StatusCode: StatusCode.Success,
183
- // can be customized
184
- ConditionsNotBefore: now,
185
- ConditionsNotOnOrAfter: OneMinutesLater,
186
- SubjectConfirmationDataNotOnOrAfter: OneMinutesLater,
187
- NameIDFormat: selectedNameIDFormat,
188
- NameID: user?.NameID || '',
189
- InResponseTo: get(requestInfo, 'extract.request.id', ''),
190
- AuthnStatement: `<saml:AuthnStatement AuthnInstant="${now}" SessionNotOnOrAfter="${tenHoursLater}" SessionIndex="${sessionIndex}"><saml:AuthnContext><saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml:AuthnContextClassRef></saml:AuthnContext></saml:AuthnStatement>`,
191
- AttributeStatement: libsaml.attributeStatementBuilder(AttributeStatement),
192
- };
193
- if (idpSetting.loginResponseTemplate && customTagReplacement) {
194
- const template = customTagReplacement(idpSetting.loginResponseTemplate.context);
195
- rawSamlResponse = get(template, 'context', null);
196
- }
197
- else {
198
- if (requestInfo !== null) {
199
- tvalue.InResponseTo = requestInfo?.extract?.request?.id ?? '';
200
- }
201
- rawSamlResponse = libsaml.replaceTagsByValue(libsaml.defaultLoginResponseTemplate.context, tvalue);
202
- }
203
- const { privateKey, privateKeyPass, requestSignatureAlgorithm: signatureAlgorithm } = idpSetting;
204
- const config = {
205
- privateKey,
206
- privateKeyPass,
207
- signatureAlgorithm,
208
- signingCert: metadata.idp.getX509Certificate('signing'),
209
- isBase64Output: false,
210
- };
211
- // step: sign assertion ? -> encrypted ? -> sign message ?
212
- if (metadata.sp.isWantAssertionsSigned()) {
213
- rawSamlResponse = libsaml.constructSAMLSignature({
214
- ...config,
215
- rawSamlMessage: rawSamlResponse,
216
- transformationAlgorithms: spSetting.transformationAlgorithms,
217
- referenceTagXPath: "/*[local-name(.)='Response']/*[local-name(.)='Assertion']",
218
- signatureConfig: {
219
- prefix: 'ds',
220
- location: {
221
- reference: "/*[local-name(.)='Response']/*[local-name(.)='Assertion']/*[local-name(.)='Issuer']",
222
- action: 'after'
223
- },
224
- },
225
- });
226
- }
227
- // console.debug('after assertion signed', rawSamlResponse);
228
- // SAML response must be signed sign message first, then encrypt
229
- if (!encryptThenSign && (spSetting.wantMessageSigned || !metadata.sp.isWantAssertionsSigned())) {
230
- // console.debug('sign then encrypt and sign entire message');
231
- // @ts-ignore
232
- rawSamlResponse = libsaml.constructSAMLSignature({
233
- ...config,
234
- rawSamlMessage: rawSamlResponse,
235
- isMessageSigned: true,
236
- transformationAlgorithms: spSetting.transformationAlgorithms,
237
- signatureConfig: spSetting.signatureConfig || {
238
- prefix: 'ds',
239
- location: { reference: "/*[local-name(.)='Response']/*[local-name(.)='Issuer']", action: 'after' },
240
- },
241
- });
242
- }
243
- // console.debug('after message signed', rawSamlResponse);
244
- if (idpSetting.isAssertionEncrypted) {
245
- // console.debug('idp is configured to do encryption');
246
- const context = await libsaml.encryptAssertion(entity.idp, entity.sp, rawSamlResponse);
247
- if (encryptThenSign) {
248
- //need to decode it
249
- rawSamlResponse = utility.base64Decode(context);
250
- }
251
- else {
252
- return Promise.resolve({ id, context });
253
- }
254
- }
255
- //sign after encrypting
256
- if (encryptThenSign && (spSetting.wantMessageSigned || !metadata.sp.isWantAssertionsSigned())) {
257
- // @ts-ignore
258
- rawSamlResponse = libsaml.constructSAMLSignature({
259
- ...config,
260
- rawSamlMessage: rawSamlResponse,
261
- isMessageSigned: true,
262
- transformationAlgorithms: spSetting.transformationAlgorithms,
263
- signatureConfig: spSetting.signatureConfig || {
264
- prefix: 'ds',
265
- location: { reference: "/*[local-name(.)='Response']/*[local-name(.)='Issuer']", action: 'after' },
266
- },
267
- });
268
- }
269
- return Promise.resolve({
270
- id,
271
- context: utility.base64Encode(rawSamlResponse),
272
- });
175
+ if (!metadata.idp || !metadata.sp) {
176
+ throw new Error('ERR_GENERATE_ARTIFACT_MISSING_METADATA');
273
177
  }
274
- throw new Error('ERR_GENERATE_POST_LOGIN_RESPONSE_MISSING_METADATA');
178
+ // 1. Generate the base SAML Response (Base64 encoded)
179
+ const samlResponseResult = await postBinding.base64LoginResponse(params);
180
+ const samlResponseXml = utility.base64Decode(samlResponseResult.context);
181
+ console.log(samlResponseXml);
182
+ console.log("我日你妈==");
183
+ // 2. Generate the SAML 2.0 Artifact ID
184
+ const artifactId = generateArtifactId(metadata.idp.getEntityID());
185
+ // 3. Cache the SAML Response XML using the artifactId as the key
186
+ // TODO: Implement your Redis caching logic here
187
+ // Example:
188
+ // const redisExpirySeconds = 300; // e.g., expire after 5 minutes
189
+ // await redis.setex(`artifact_cache:${artifactId}`, redisExpirySeconds, samlResponseXml);
190
+ // --- INSERT YOUR REDIS LOGIC HERE ---
191
+ // Example: await cacheInRedis(artifactId, samlResponseXml);
192
+ // 4. Prepare config for SOAP signing (reusing IDP settings)
193
+ const idpSetting = entity.idp.entitySetting;
194
+ const spSetting = entity.sp.entitySetting;
195
+ const config = {
196
+ privateKey: idpSetting.privateKey,
197
+ privateKeyPass: idpSetting.privateKeyPass,
198
+ signatureAlgorithm: idpSetting.requestSignatureAlgorithm,
199
+ signingCert: metadata.idp.getX509Certificate('signing'),
200
+ isBase64Output: false,
201
+ };
202
+ // 5. Construct the SOAP Envelope containing the Artifact ID
203
+ // This is CORRECT - in the initial response we only send the artifact ID
204
+ // The actual SAML response will be retrieved by the SP using the artifact resolution service
205
+ const soapBodyContent = `<samlp:ArtifactResponse
206
+ xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
207
+ xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
208
+ ID="${samlResponseResult.id}_artifact_resp"
209
+ InResponseTo="${params.requestInfo?.extract?.request?.id || ''}"
210
+ Version="2.0"
211
+ IssueInstant="${new Date().toISOString()}">
212
+ <saml2:Issuer>${metadata.idp.getEntityID()}</saml2:Issuer>
213
+ <samlp:Status>
214
+ <samlp:StatusCode Value="${StatusCode.Success}"/>
215
+ </samlp:Status>
216
+ ${samlResponseXml}
217
+ </samlp:ArtifactResponse>`;
218
+ const soapEnvelope = `
219
+ <soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
220
+ <soap:Header/>
221
+ <soap:Body>${soapBodyContent}</soap:Body>
222
+ </soap:Envelope>`;
223
+ // 6. Sign the SOAP Envelope
224
+ // Reference the Body element for signature
225
+ // @ts-ignore
226
+ // @ts-ignore
227
+ const signedSoapEnvelope = libsaml.constructSAMLSignature({
228
+ ...config,
229
+ rawSamlMessage: soapEnvelope,
230
+ transformationAlgorithms: spSetting.transformationAlgorithms,
231
+ isMessageSigned: true,
232
+ referenceTagXPath: "/*[local-name(.)='Envelope']/*[local-name(.)='Body']/*[local-name(.)='ArtifactResponse']",
233
+ signatureConfig: {
234
+ prefix: 'ds',
235
+ location: {
236
+ // Place signature inside the ArtifactResponse element, before the Status element
237
+ reference: "/*[local-name(.)='Envelope']/*[local-name(.)='Body']/*[local-name(.)='ArtifactResponse']/*[local-name(.)='Status']",
238
+ action: 'before'
239
+ },
240
+ },
241
+ });
242
+ // 7. Return the Artifact ID and the Base64 encoded signed SOAP message
243
+ return {
244
+ id: artifactId, // This is the key you'll need for resolving the artifact later
245
+ context: utility.base64Encode(signedSoapEnvelope), // The SOAP message to send back
246
+ };
275
247
  }
276
248
  async function parseLoginRequestResolve(params) {
277
249
  let { idp, sp, xml, } = params;
@@ -10,6 +10,7 @@ import { namespace } from './urn.js';
10
10
  import postBinding from './binding-post.js';
11
11
  import redirectBinding from './binding-redirect.js';
12
12
  import simpleSignBinding from './binding-simplesign.js';
13
+ import artifactBinding from './binding-artifact.js';
13
14
  import { flow } from './flow.js';
14
15
  /**
15
16
  * Identity provider can be configured using either metadata importing or idpSetting
@@ -36,7 +37,7 @@ export class IdentityProvider extends Entity {
36
37
  * @param params
37
38
  */
38
39
  async createLoginResponse(params) {
39
- const bindType = params?.binding ?? 'post';
40
+ const bindType = (params?.binding ?? 'post');
40
41
  const { sp, requestInfo = {}, user = {}, customTagReplacement, encryptThenSign = false, relayState = '', AttributeStatement = [], idpInit = false, } = params;
41
42
  const protocol = namespace.binding[bindType];
42
43
  // can support post, redirect and post simple sign bindings for login response
@@ -66,6 +67,19 @@ export class IdentityProvider extends Entity {
66
67
  idp: this,
67
68
  sp,
68
69
  }, user, relayState, customTagReplacement, AttributeStatement);
70
+ case namespace.binding.artifact:
71
+ return artifactBinding.soapLoginResponse({
72
+ requestInfo,
73
+ entity: {
74
+ idp: this,
75
+ sp,
76
+ },
77
+ user,
78
+ customTagReplacement,
79
+ encryptThenSign,
80
+ AttributeStatement,
81
+ idpInit,
82
+ });
69
83
  default:
70
84
  throw new Error('ERR_CREATE_RESPONSE_UNDEFINED_BINDING');
71
85
  }
@@ -2,6 +2,13 @@ import { select } from 'xpath';
2
2
  import { uniq, last, zipObject, notEmpty } from './utility.js'; // 假设这些工具函数存在
3
3
  import { getContext } from './api.js'; // 假设这个API存在
4
4
  import camelCase from 'camelcase';
5
+ function toNodeArray(result) {
6
+ if (Array.isArray(result))
7
+ return result;
8
+ if (result != null && typeof result === 'object' && 'nodeType' in result)
9
+ return [result];
10
+ return [];
11
+ }
5
12
  function buildAbsoluteXPath(paths) {
6
13
  if (!paths || paths.length === 0)
7
14
  return '';
@@ -110,10 +117,27 @@ export const loginRequestFields = [
110
117
  { key: 'signature', localPath: ['AuthnRequest', 'Signature'], attributes: [], context: true }
111
118
  ];
112
119
  export const artifactResolveFields = [
113
- { key: 'request', localPath: ['ArtifactResolve'], attributes: ['ID', 'IssueInstant', 'Version'] },
114
- { key: 'issuer', localPath: ['ArtifactResolve', 'Issuer'], attributes: [] },
115
- { key: 'Artifact', localPath: ['ArtifactResolve', 'Artifact'], attributes: [] },
116
- { key: 'signature', localPath: ['ArtifactResolve', 'Signature'], attributes: [], context: true },
120
+ {
121
+ key: 'request',
122
+ localPath: ['Envelope', 'Body', 'ArtifactResolve'],
123
+ attributes: ['ID', 'IssueInstant', 'Version', 'Destination']
124
+ },
125
+ {
126
+ key: 'issuer',
127
+ localPath: ['Envelope', 'Body', 'ArtifactResolve', 'Issuer'],
128
+ attributes: []
129
+ },
130
+ {
131
+ key: 'artifact',
132
+ localPath: ['Envelope', 'Body', 'ArtifactResolve', 'Artifact'],
133
+ attributes: []
134
+ },
135
+ {
136
+ key: 'signature',
137
+ localPath: ['Envelope', 'Body', 'ArtifactResolve', 'Signature'],
138
+ attributes: [],
139
+ context: true
140
+ },
117
141
  ];
118
142
  export const artifactResponseFields = [
119
143
  { key: 'request', localPath: ['Envelope', 'Body', 'ArtifactResolve'], attributes: ['ID', 'IssueInstant', 'Version'] },
@@ -693,7 +717,7 @@ export function extract(context, fields) {
693
717
  }
694
718
  try {
695
719
  // @ts-ignore
696
- const nodes = select(fullXPath, targetDoc);
720
+ const nodes = toNodeArray(select(fullXPath, targetDoc));
697
721
  if (isKeyName) {
698
722
  const keyNames = nodes.map((n) => n.nodeValue).filter(notEmpty);
699
723
  return {
@@ -721,7 +745,7 @@ export function extract(context, fields) {
721
745
  if (Array.isArray(localPath) && localPath.length > 0 && Array.isArray(localPath[0])) {
722
746
  const multiXPaths = localPath.map(path => `${buildAbsoluteXPath(path)}/text()`).join(' | ');
723
747
  // @ts-ignore
724
- const nodes = select(multiXPaths, targetDoc);
748
+ const nodes = toNodeArray(select(multiXPaths, targetDoc));
725
749
  return {
726
750
  ...result,
727
751
  [key]: uniq(nodes.map((n) => n.nodeValue).filter(notEmpty))
@@ -738,7 +762,7 @@ export function extract(context, fields) {
738
762
  // --- 新增:列表模式处理 (用于 SSO Service, ACS 等) ---
739
763
  if (listMode && attributes.length > 0) {
740
764
  // @ts-ignore
741
- const nodes = select(baseXPath, targetDoc);
765
+ const nodes = toNodeArray(select(baseXPath, targetDoc));
742
766
  const resultList = nodes.map((node) => {
743
767
  const attrResult = {};
744
768
  attributes.forEach(attr => {
@@ -762,7 +786,7 @@ export function extract(context, fields) {
762
786
  const indexPath = buildAttributeXPath(index);
763
787
  const fullLocalXPath = `${baseXPath}${indexPath}`;
764
788
  // @ts-ignore
765
- const parentNodes = select(baseXPath, targetDoc);
789
+ const parentNodes = toNodeArray(select(baseXPath, targetDoc));
766
790
  // @ts-ignore
767
791
  const parentAttributes = select(fullLocalXPath, targetDoc).map((n) => n.value);
768
792
  const childXPath = buildAbsoluteXPath([last(currentLocalPath)].concat(attributePath));
@@ -788,7 +812,7 @@ export function extract(context, fields) {
788
812
  // 特殊 case: 获取整个节点内容 (原有逻辑)
789
813
  if (isEntire) {
790
814
  // @ts-ignore
791
- const node = select(baseXPath, targetDoc);
815
+ const node = toNodeArray(select(baseXPath, targetDoc));
792
816
  let value = null;
793
817
  if (node.length === 1) {
794
818
  value = node[0].toString();
@@ -829,7 +853,7 @@ export function extract(context, fields) {
829
853
  if (attributes.length === 0 && !listMode) {
830
854
  let attributeValue = null;
831
855
  // @ts-ignore
832
- const node = select(baseXPath, targetDoc);
856
+ const node = toNodeArray(select(baseXPath, targetDoc));
833
857
  if (node.length === 1) {
834
858
  const fullPath = `string(${baseXPath}${attributeXPath})`;
835
859
  // @ts-ignore
@@ -1258,7 +1282,7 @@ export function extractSpToll(context, fields) {
1258
1282
  }
1259
1283
  try {
1260
1284
  // @ts-ignore
1261
- const nodes = select(fullXPath, targetDoc);
1285
+ const nodes = toNodeArray(select(fullXPath, targetDoc));
1262
1286
  if (isKeyName) {
1263
1287
  const keyNames = nodes.map((n) => n.nodeValue).filter(notEmpty);
1264
1288
  return { ...result, [key]: keyNames.length > 0 ? keyNames[0] : null };
@@ -1280,7 +1304,7 @@ export function extractSpToll(context, fields) {
1280
1304
  const multiXPaths = localPath.map(path => `${buildAbsoluteXPath(path)}/text()`).join(' | ');
1281
1305
  try {
1282
1306
  // @ts-ignore
1283
- const nodes = select(multiXPaths, targetDoc);
1307
+ const nodes = toNodeArray(select(multiXPaths, targetDoc));
1284
1308
  return { ...result, [key]: uniq(nodes.map((n) => n.nodeValue).filter(notEmpty)) };
1285
1309
  }
1286
1310
  catch (e) {
@@ -1296,7 +1320,7 @@ export function extractSpToll(context, fields) {
1296
1320
  if (listMode) {
1297
1321
  try {
1298
1322
  // @ts-ignore
1299
- const nodes = select(baseXPath, targetDoc);
1323
+ const nodes = toNodeArray(select(baseXPath, targetDoc));
1300
1324
  if (parseCallback) {
1301
1325
  // 使用自定义回调函数处理列表
1302
1326
  return { ...result, [key]: parseCallback(nodes) };
@@ -1337,7 +1361,7 @@ export function extractSpToll(context, fields) {
1337
1361
  const indexPath = buildAttributeXPath(index);
1338
1362
  const fullLocalXPath = `${baseXPath}${indexPath}`;
1339
1363
  // @ts-ignore
1340
- const parentNodes = select(baseXPath, targetDoc);
1364
+ const parentNodes = toNodeArray(select(baseXPath, targetDoc));
1341
1365
  // @ts-ignore
1342
1366
  const parentAttributes = select(fullLocalXPath, targetDoc).map((n) => n.value);
1343
1367
  const childXPath = buildAbsoluteXPath([last(currentLocalPath)].concat(attributePath));
@@ -1376,7 +1400,7 @@ export function extractSpToll(context, fields) {
1376
1400
  if (isEntire) {
1377
1401
  try {
1378
1402
  // @ts-ignore
1379
- const node = select(baseXPath, targetDoc);
1403
+ const node = toNodeArray(select(baseXPath, targetDoc));
1380
1404
  let value = null;
1381
1405
  if (node.length === 1) {
1382
1406
  value = node[0].toString();
@@ -1399,7 +1423,7 @@ export function extractSpToll(context, fields) {
1399
1423
  if (attributes.length > 1 && !listMode) {
1400
1424
  try {
1401
1425
  // @ts-ignore
1402
- const baseNodeList = select(baseXPath, targetDoc);
1426
+ const baseNodeList = toNodeArray(select(baseXPath, targetDoc));
1403
1427
  if (baseNodeList.length === 0)
1404
1428
  return { ...result, [key]: null };
1405
1429
  const attributeValues = baseNodeList.map((node) => {
@@ -1438,7 +1462,7 @@ export function extractSpToll(context, fields) {
1438
1462
  if (attributes.length === 0 && !listMode) {
1439
1463
  try {
1440
1464
  // @ts-ignore
1441
- const node = select(baseXPath, targetDoc);
1465
+ const node = toNodeArray(select(baseXPath, targetDoc));
1442
1466
  if (parseCallback) {
1443
1467
  // 使用自定义回调函数处理单个节点
1444
1468
  return { ...result, [key]: parseCallback(node[0]) };
@@ -1447,7 +1471,7 @@ export function extractSpToll(context, fields) {
1447
1471
  if (node.length === 1) {
1448
1472
  const fullPath = `string(${baseXPath})`;
1449
1473
  // @ts-ignore
1450
- attributeValue = select(fullPath, targetDoc);
1474
+ attributeValue = toNodeArray(select(fullPath, targetDoc));
1451
1475
  }
1452
1476
  if (node.length > 1) {
1453
1477
  attributeValue = node.filter((n) => n.firstChild)
@@ -1480,6 +1504,9 @@ export function extractSp(context) {
1480
1504
  export function extractAuthRequest(context) {
1481
1505
  return extract(context, loginRequestFields);
1482
1506
  }
1483
- export function extractResponse(context, ass) {
1507
+ export function extractResponse(context) {
1484
1508
  return extractSpToll(context, loginResponseFieldsFullList);
1485
1509
  }
1510
+ export function extractArtifactResolve(context) {
1511
+ return extract(context, artifactResolveFields);
1512
+ }