samlesa 4.3.2 → 4.3.4

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.
@@ -8,6 +8,37 @@ import { namespace, elementsOrder as order } from './urn.js';
8
8
  import libsaml from './libsaml.js';
9
9
  import { castArrayOpt, isNonEmptyArray, isString } from './utility.js';
10
10
  import xml from 'xml';
11
+ import { select } from 'xpath';
12
+ import { getContext } from './api.js';
13
+ function toNodeArray(result) {
14
+ if (Array.isArray(result))
15
+ return result;
16
+ if (result)
17
+ return [result];
18
+ return [];
19
+ }
20
+ function unwrapSingleEntityDescriptorMetadata(meta) {
21
+ const metadataText = meta.toString();
22
+ if (!/<(?:\w+:)?EntitiesDescriptor\b/i.test(metadataText)) {
23
+ return meta;
24
+ }
25
+ const { dom } = getContext();
26
+ const rootDoc = dom.parseFromString(metadataText, 'application/xml');
27
+ const parserErrors = rootDoc.getElementsByTagName('parsererror');
28
+ if (parserErrors.length > 0) {
29
+ return meta;
30
+ }
31
+ const entityNodes = toNodeArray(
32
+ // @ts-ignore
33
+ select("/*[local-name(.)='EntitiesDescriptor']/*[local-name(.)='EntityDescriptor']", rootDoc));
34
+ if (entityNodes.length === 0) {
35
+ return meta;
36
+ }
37
+ if (entityNodes.length > 1) {
38
+ throw new Error('ERR_MULTIPLE_METADATA_ENTITYDESCRIPTOR');
39
+ }
40
+ return entityNodes[0].toString();
41
+ }
11
42
  /*
12
43
  * @desc interface function
13
44
  */
@@ -24,6 +55,10 @@ export class SpMetadata extends Metadata {
24
55
  */
25
56
  constructor(meta) {
26
57
  const isFile = isString(meta) || meta instanceof Buffer;
58
+ let normalizedMeta = meta;
59
+ if (isFile) {
60
+ normalizedMeta = unwrapSingleEntityDescriptorMetadata(meta);
61
+ }
27
62
  // use object configuration instead of importing metadata file directly
28
63
  if (!isFile) {
29
64
  const settings = meta;
@@ -186,7 +221,7 @@ export class SpMetadata extends Metadata {
186
221
  descriptors[name].forEach(e => SPSSODescriptor.push({ [name]: e }));
187
222
  });
188
223
  // Re-assign the meta reference as a XML string|Buffer for use with the parent constructor
189
- meta = xml([{
224
+ normalizedMeta = xml([{
190
225
  EntityDescriptor: [{
191
226
  _attr: {
192
227
  entityID,
@@ -198,7 +233,7 @@ export class SpMetadata extends Metadata {
198
233
  }]);
199
234
  }
200
235
  // Use the re-assigned meta object reference here
201
- super(meta, [
236
+ super(normalizedMeta, [
202
237
  {
203
238
  key: 'spSSODescriptor',
204
239
  localPath: ['EntityDescriptor', 'SPSSODescriptor'],
@@ -238,177 +273,136 @@ export class SpMetadata extends Metadata {
238
273
  }
239
274
  return value === 'true';
240
275
  }
241
- /**
242
- * @desc Get the entity endpoint for assertion consumer service
243
- * @param {string} binding protocol binding (e.g. redirect, post)
244
- * @return {string/[string]} URL of endpoint(s)
245
- */
246
- getAssertionConsumerService(binding) {
247
- // 1. 预处理 binding 参数
248
- // 如果 binding 不是字符串,视为“未指定 Binding 偏好”,直接进入全局兜底逻辑 (Fallback Mode)
249
- let targetBindingName = null;
250
- let useFallbackMode = false;
251
- if (typeof binding === 'string') {
252
- const resolved = namespace.binding[binding];
253
- if (resolved) {
254
- targetBindingName = resolved;
276
+ getAssertionConsumerService(binding, options = {}) {
277
+ const mode = options.mode ?? 'lenient';
278
+ const normalizeBinding = (value) => String(value ?? '').trim().toLowerCase();
279
+ const normalizeLocation = (value) => String(value ?? '').trim();
280
+ const parseBool = (value) => {
281
+ if (typeof value === 'boolean')
282
+ return value;
283
+ if (typeof value === 'string') {
284
+ const t = value.trim().toLowerCase();
285
+ if (t === 'true')
286
+ return true;
287
+ if (t === 'false')
288
+ return false;
255
289
  }
256
- else {
257
- // 字符串传入了但 namespace 里没找到,也视为无效,进入兜底
258
- //console.warn(`[ACS Selection] 未知的 binding 键值: ${binding}, 将启用全局兜底策略.`);
259
- useFallbackMode = true;
290
+ return null;
291
+ };
292
+ const parseIndex = (value) => {
293
+ if (value === undefined || value === null || String(value).trim() === '')
294
+ return Number.POSITIVE_INFINITY;
295
+ const n = Number.parseInt(String(value), 10);
296
+ return Number.isNaN(n) ? Number.POSITIVE_INFINITY : n;
297
+ };
298
+ const indexHint = parseIndex(options.assertionConsumerServiceIndex);
299
+ const hasIndexHint = Number.isFinite(indexHint);
300
+ const toSafeLocation = (value) => {
301
+ const text = String(value || '').trim();
302
+ // 仅校验协议前缀,保持原始字符串不被规范化改写(兼容历史用例:如 "https:sp.example.org/...")
303
+ if (/^https?:/i.test(text)) {
304
+ return text;
260
305
  }
261
- }
262
- else {
263
- // 非字符串类型 (null, undefined, object 等),直接启用兜底
264
- // console.warn(`[ACS Selection] binding 参数类型错误 (${typeof binding}), 将启用全局兜底策略.`);
265
- useFallbackMode = true;
266
- }
267
- const acsData = this.meta.assertionConsumerService;
268
- // 2. 标准化数据为数组
269
- let allCandidates = [];
270
- if (Array.isArray(acsData)) {
271
- allCandidates = acsData;
272
- }
273
- else if (acsData && typeof acsData === 'object') {
274
- allCandidates = [acsData];
275
- }
276
- else {
277
- // 数据源本身为空,直接报错
278
- return "";
279
- /* throw new Error("SAML Metadata Error: No AssertionConsumerService definitions found in metadata.");*/
280
- }
281
- if (allCandidates.length === 0) {
282
- return "";
283
- /* throw new Error("SAML Metadata Error: AssertionConsumerService list is empty.");*/
284
- }
285
- /**
286
- * 核心排序与选择函数
287
- * 输入一个候选数组,返回最佳的一个对象
288
- */
289
- const selectBestFromList = (list) => {
306
+ return '';
307
+ };
308
+ const resolveBindingUri = (input) => {
309
+ if (typeof input !== 'string')
310
+ return null;
311
+ const raw = input.trim();
312
+ if (!raw)
313
+ return null;
314
+ const byKey = namespace.binding[raw];
315
+ if (typeof byKey === 'string')
316
+ return byKey;
317
+ const lower = raw.toLowerCase();
318
+ const candidates = Object.values(namespace.binding).filter((v) => typeof v === 'string');
319
+ const byUri = candidates.find((v) => v.toLowerCase() === lower);
320
+ if (byUri)
321
+ return byUri;
322
+ const aliasMap = {
323
+ post: namespace.binding.post,
324
+ redirect: namespace.binding.redirect,
325
+ artifact: namespace.binding.artifact,
326
+ simplesign: namespace.binding.simpleSign,
327
+ 'simple-sign': namespace.binding.simpleSign,
328
+ soap: namespace.binding.soap,
329
+ };
330
+ return aliasMap[lower] || null;
331
+ };
332
+ const sortCandidates = (list) => [...list].sort((a, b) => {
333
+ const score = (item) => item.isDefault === true ? 0 : (item.isDefault === null ? 1 : 2);
334
+ const sA = score(a);
335
+ const sB = score(b);
336
+ if (sA !== sB)
337
+ return sA - sB;
338
+ if (a.index !== b.index)
339
+ return a.index - b.index;
340
+ return a.order - b.order;
341
+ });
342
+ const pickBest = (list) => {
290
343
  if (list.length === 0)
291
- return undefined;
292
- if (list.length === 1)
293
- return list[0];
294
- const tier1 = []; // isDefault === 'true'
295
- const tier2 = []; // isDefault 不存在 或 不为 'false'
296
- const tier3 = []; // 明确 isDefault === 'false'
297
- list.forEach((item, originalIndex) => {
298
- const isDef = item.isDefault;
299
- const isTrue = (typeof isDef === 'string' && isDef.toLowerCase() === 'true');
300
- const isFalse = (typeof isDef === 'string' && isDef.toLowerCase() === 'false');
301
- if (isTrue) {
302
- tier1.push(item);
303
- }
304
- else if (!isFalse) {
305
- tier2.push(item);
306
- }
307
- else {
308
- tier3.push(item);
309
- }
310
- });
311
- let selectedTier = [];
312
- if (tier1.length > 0) {
313
- selectedTier = tier1;
314
- }
315
- else if (tier2.length > 0) {
316
- selectedTier = tier2;
317
- }
318
- else {
319
- selectedTier = tier3;
320
- }
321
- // 二级排序:Index 升序 -> 原始顺序
322
- const indexedTier = selectedTier.map((item, idx) => ({ item, originalIdx: idx }));
323
- indexedTier.sort((a, b) => {
324
- // 安全解析 index
325
- let idxA = Infinity;
326
- let idxB = Infinity;
327
- if (a.item.index !== undefined && a.item.index !== null) {
328
- const parsed = parseInt(a.item.index, 10);
329
- if (!isNaN(parsed))
330
- idxA = parsed;
331
- }
332
- if (b.item.index !== undefined && b.item.index !== null) {
333
- const parsed = parseInt(b.item.index, 10);
334
- if (!isNaN(parsed))
335
- idxB = parsed;
336
- }
337
- if (idxA !== idxB) {
338
- return idxA - idxB;
339
- }
340
- return a.originalIdx - b.originalIdx;
341
- });
342
- return indexedTier[0].item;
344
+ return null;
345
+ return sortCandidates(list)[0] ?? null;
343
346
  };
344
- let resultLocation = undefined;
345
- // ==========================================
346
- // 第一阶段:尝试在指定的 binding 中查找 (仅在非兜底模式下执行)
347
- // ==========================================
348
- if (!useFallbackMode && targetBindingName) {
349
- const matchedByBinding = allCandidates.filter(obj => obj.binding === targetBindingName);
350
- // console.log(`[ACS Selection] 目标 Binding: ${targetBindingName}`);
351
- //console.log(`[ACS Selection] 匹配该 Binding 的数量: ${matchedByBinding.length}`);
352
- if (matchedByBinding.length > 0) {
353
- const bestInBinding = selectBestFromList(matchedByBinding);
354
- if (bestInBinding) {
355
- // console.log(`[ACS Selection] (阶段1) 在指定 Binding 中找到最佳项: ${bestInBinding.location}`);
356
- resultLocation = bestInBinding.location;
357
- }
358
- }
347
+ const pickByIndex = (list) => {
348
+ if (!hasIndexHint)
349
+ return null;
350
+ const hit = list.filter((item) => item.index === indexHint);
351
+ return pickBest(hit);
352
+ };
353
+ const acsData = this.meta.assertionConsumerService;
354
+ const rawCandidates = Array.isArray(acsData) ? acsData : (acsData && typeof acsData === 'object' ? [acsData] : []);
355
+ if (rawCandidates.length === 0)
356
+ return '';
357
+ const normalizedCandidates = rawCandidates
358
+ .map((item, order) => ({
359
+ binding: String(item?.binding ?? item?.Binding ?? '').trim(),
360
+ location: toSafeLocation(normalizeLocation(item?.location ?? item?.Location ?? item?.responseLocation ?? item?.ResponseLocation ?? '')),
361
+ isDefault: parseBool(item?.isDefault ?? item?.IsDefault ?? item?.default),
362
+ index: parseIndex(item?.index ?? item?.Index),
363
+ order,
364
+ }))
365
+ .filter((item) => item.location);
366
+ if (normalizedCandidates.length === 0)
367
+ return '';
368
+ const dedupMap = new Map();
369
+ for (const item of normalizedCandidates) {
370
+ const key = `${normalizeBinding(item.binding)}|${item.location}|${Number.isFinite(item.index) ? item.index : 'NA'}`;
371
+ if (!dedupMap.has(key))
372
+ dedupMap.set(key, item);
359
373
  }
360
- // ==========================================
361
- // 第二阶段:降级/兜底策略
362
- // 如果第一阶段没找到,或者一开始就进入了兜底模式 (useFallbackMode)
363
- // ==========================================
364
- if (!resultLocation) {
365
- if (useFallbackMode) {
366
- //console.log(`[ACS Selection] ⚠️ 启用全局兜底策略 (未指定有效 Binding)...`);
367
- }
368
- else {
369
- //console.log(`[ACS Selection] ⚠️ 指定 Binding 未找到可用项,进入降级策略...`);
370
- }
371
- // 2.1 全局寻找 isDefault='true'
372
- const globalDefaults = allCandidates.filter(obj => obj.isDefault && String(obj.isDefault).toLowerCase() === 'true');
373
- if (globalDefaults.length > 0) {
374
- const best = selectBestFromList(globalDefaults);
375
- if (best) {
376
- //console.log(`[ACS Selection] ✅ (阶段2) 跨 Binding 找到 isDefault=true: ${best.location} (${best.binding})`);
377
- resultLocation = best.location;
378
- }
379
- }
380
- // 2.2 全局寻找 isDefault 不为 'false' (隐式默认)
381
- if (!resultLocation) {
382
- const globalNonFalse = allCandidates.filter(obj => {
383
- const isDef = obj.isDefault;
384
- const isFalse = (typeof isDef === 'string' && isDef.toLowerCase() === 'false');
385
- return !isFalse;
386
- });
387
- if (globalNonFalse.length > 0) {
388
- const best = selectBestFromList(globalNonFalse);
389
- if (best) {
390
- //console.log(`[ACS Selection] ✅ (阶段3) 跨 Binding 找到隐式默认: ${best.location} (${best.binding})`);
391
- resultLocation = best.location;
392
- }
393
- }
394
- }
395
- // 2.3 终极兜底:所有数据中 index 最小
396
- if (!resultLocation) {
397
- const best = selectBestFromList(allCandidates);
398
- if (best) {
399
- // console.log(`[ACS Selection] ✅ (阶段4) 终极兜底,选择 index 最小: ${best.location} (${best.binding})`);
400
- resultLocation = best.location;
401
- }
374
+ const allCandidates = Array.from(dedupMap.values());
375
+ const targetBindingName = resolveBindingUri(binding);
376
+ const boundCandidates = targetBindingName
377
+ ? allCandidates.filter((item) => normalizeBinding(item.binding) === normalizeBinding(targetBindingName))
378
+ : [];
379
+ const warnDefaultConflict = (list, label) => {
380
+ const defaults = list.filter((item) => item.isDefault === true);
381
+ if (defaults.length > 1) {
382
+ console.warn(`[ACS Selection] Multiple isDefault=true found in ${label}; fallback to index/order.`);
402
383
  }
384
+ };
385
+ warnDefaultConflict(targetBindingName ? boundCandidates : allCandidates, targetBindingName ? `binding=${targetBindingName}` : 'all bindings');
386
+ // 1) index 优先:先同 binding,再全局(仅 lenient)
387
+ const indexedInBinding = pickByIndex(boundCandidates);
388
+ if (indexedInBinding)
389
+ return indexedInBinding.location;
390
+ if (targetBindingName && mode === 'strict' && boundCandidates.length === 0) {
391
+ return '';
403
392
  }
404
- // ==========================================
405
- // 最终检查:如果还是没找到,抛出错误
406
- // ==========================================
407
- if (!resultLocation) {
408
- const errorMsg = "SAML Configuration Error: Unable to select any valid AssertionConsumerService URL. Metadata might be empty or malformed.";
409
- console.error(`[ACS Selection] ❌ ${errorMsg}`);
410
- throw new Error(errorMsg);
393
+ if (mode === 'lenient') {
394
+ const indexedGlobal = pickByIndex(allCandidates);
395
+ if (indexedGlobal)
396
+ return indexedGlobal.location;
411
397
  }
412
- return resultLocation;
398
+ // 2) 绑定内最优
399
+ const bestBound = pickBest(boundCandidates);
400
+ if (bestBound)
401
+ return bestBound.location;
402
+ if (mode === 'strict' && targetBindingName)
403
+ return '';
404
+ // 3) 全局回退
405
+ const bestGlobal = pickBest(allCandidates);
406
+ return bestGlobal?.location ?? '';
413
407
  }
414
408
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "samlesa",
3
- "version": "4.3.2",
3
+ "version": "4.3.4",
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": [
@@ -7,6 +7,11 @@ import Metadata, { type MetadataInterface } from './metadata.js';
7
7
  import type { MetadataSpConstructor } from './types.js';
8
8
  export interface SpMetadataInterface extends MetadataInterface {
9
9
  }
10
+ type AcsSelectionMode = 'strict' | 'lenient';
11
+ interface AcsSelectionOptions {
12
+ mode?: AcsSelectionMode;
13
+ assertionConsumerServiceIndex?: string | number | null;
14
+ }
10
15
  export default function (meta: MetadataSpConstructor): SpMetadata;
11
16
  /**
12
17
  * @desc SP Metadata is for creating Service Provider, provides a set of API to manage the actions in SP.
@@ -32,6 +37,8 @@ export declare class SpMetadata extends Metadata {
32
37
  * @param {string} binding protocol binding (e.g. redirect, post)
33
38
  * @return {string/[string]} URL of endpoint(s)
34
39
  */
35
- getAssertionConsumerService(binding: string): string;
40
+ getAssertionConsumerService(binding?: string): string;
41
+ getAssertionConsumerService(binding: string | undefined, options: AcsSelectionOptions): string;
36
42
  }
43
+ export {};
37
44
  //# sourceMappingURL=metadata-sp.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"metadata-sp.d.ts","sourceRoot":"","sources":["../../src/metadata-sp.ts"],"names":[],"mappings":"AAAA;;;;EAIE;AACF,OAAO,QAAQ,EAAE,EAAC,KAAK,iBAAiB,EAAC,MAAM,eAAe,CAAC;AAE/D,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAC;AAMxD,MAAM,WAAW,mBAAoB,SAAQ,iBAAiB;CAE7D;AAeD,MAAM,CAAC,OAAO,WAAU,IAAI,EAAE,qBAAqB,cAElD;AAED;;EAEE;AACF,qBAAa,UAAW,SAAQ,QAAQ;IAEtC;;;MAGE;gBACU,IAAI,EAAE,qBAAqB;IA+NvC;;;MAGE;IACK,sBAAsB,IAAI,OAAO;IAOxC;;;MAGE;IACK,oBAAoB,IAAI,OAAO;IAOtC;;;;MAIE;IACK,2BAA2B,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM;CAwL5D"}
1
+ {"version":3,"file":"metadata-sp.d.ts","sourceRoot":"","sources":["../../src/metadata-sp.ts"],"names":[],"mappings":"AAAA;;;;EAIE;AACF,OAAO,QAAQ,EAAE,EAAC,KAAK,iBAAiB,EAAC,MAAM,eAAe,CAAC;AAE/D,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAC;AAQxD,MAAM,WAAW,mBAAoB,SAAQ,iBAAiB;CAE7D;AAYD,KAAK,gBAAgB,GAAG,QAAQ,GAAG,SAAS,CAAC;AAC7C,UAAU,mBAAmB;IAC3B,IAAI,CAAC,EAAE,gBAAgB,CAAC;IACxB,6BAA6B,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;CACxD;AAqCD,MAAM,CAAC,OAAO,WAAU,IAAI,EAAE,qBAAqB,cAElD;AAED;;EAEE;AACF,qBAAa,UAAW,SAAQ,QAAQ;IAEtC;;;MAGE;gBACU,IAAI,EAAE,qBAAqB;IAmOvC;;;MAGE;IACK,sBAAsB,IAAI,OAAO;IAOxC;;;MAGE;IACK,oBAAoB,IAAI,OAAO;IAOtC;;;;MAIE;IACK,2BAA2B,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM;IACrD,2BAA2B,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,EAAE,OAAO,EAAE,mBAAmB,GAAG,MAAM;CAmItG"}