@mcp-abap-adt/auth-providers 4.0.0 → 4.1.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.
@@ -30,6 +30,22 @@ const SAML_NS = 'urn:oasis:names:tc:SAML:2.0:assertion';
30
30
  const PROTOCOL_NS = 'urn:oasis:names:tc:SAML:2.0:protocol';
31
31
  const BEARER = 'urn:oasis:names:tc:SAML:2.0:cm:bearer';
32
32
  const SUCCESS = 'urn:oasis:names:tc:SAML:2.0:status:Success';
33
+ const SAML1_NS = 'urn:oasis:names:tc:SAML:1.0:assertion';
34
+ const DSIG_NS = 'http://www.w3.org/2000/09/xmldsig#';
35
+ /**
36
+ * Everything a later reader might take for the assertion: SAML 2.0's
37
+ * Assertion and EncryptedAssertion, and SAML 1.x's Assertion.
38
+ */
39
+ const ASSERTION_SHAPED = [
40
+ [SAML_NS, 'Assertion'],
41
+ [SAML_NS, 'EncryptedAssertion'],
42
+ [SAML1_NS, 'Assertion'],
43
+ ];
44
+ /** How a refusal names the element each validator requires to be signed. */
45
+ const REQUIRED_LABEL = {
46
+ response: 'samlp:Response',
47
+ assertion: 'saml:Assertion',
48
+ };
33
49
  /**
34
50
  * Marks a validator as one of the two shipped here. Module-private and
35
51
  * non-enumerable, so it is neither part of the public surface nor visible to
@@ -119,49 +135,61 @@ function createValidator(require, options) {
119
135
  catch (error) {
120
136
  return fail('signature', error.message);
121
137
  }
122
- // The element this validator reads is fixed by the document's shape,
123
- // not by which signature happens to come first: the Response itself, or
124
- // for the assertion-only validator the bare root Assertion or the
125
- // Response's single direct-child Assertion. Taking "the first covered
126
- // Assertion" instead would pick a signed assertion nested in Advice
127
- // whenever its signature precedes the outer one's in document order.
128
- const target = require === 'response'
129
- ? root
130
- : rootIsAssertion
131
- ? root
132
- : (() => {
133
- const children = directChildren(root, SAML_NS, 'Assertion');
134
- return children.length === 1 ? children[0] : null;
135
- })();
136
- const signed = target
137
- ? covered.find((element) => element === target)
138
- : undefined;
139
- // The signed element must be the Assertion, or a Response holding exactly
140
- // one. Everything below is read from `assertion` and nowhere else.
141
- const assertion = signed ? assertionInside(signed, root, require) : null;
142
- if (!signed || !assertion) {
143
- return fail('signedNode', 'the signature does not cover the assertion this response carries');
138
+ // 3a. A Response carries exactly one direct-child Assertion, and a
139
+ // refusal says which way the count failed. Checked once, here, for both
140
+ // validators: the signed-Response validator reads that assertion, and
141
+ // the assertion-only validator requires it to be the element signed.
142
+ const direct = rootIsResponse
143
+ ? directChildren(root, SAML_NS, 'Assertion')
144
+ : [];
145
+ if (rootIsResponse && direct.length === 0) {
146
+ return fail('signedNode', 'the response carries no direct-child saml:Assertion');
147
+ }
148
+ if (direct.length > 1) {
149
+ return fail('signedNode', `the response carries ${direct.length} direct-child saml:Assertion; exactly one is allowed`);
150
+ }
151
+ // 3b. The element this validator requires signed is fixed by the
152
+ // document's shape, not by which signature happens to come first: the
153
+ // Response itself, or for the assertion-only validator the bare root
154
+ // Assertion or the Response's single direct-child Assertion. Taking
155
+ // "the first covered Assertion" instead would pick a signed assertion
156
+ // nested in Advice whenever its signature precedes the outer one's —
157
+ // and a covered element anywhere but here is the wrapping attack.
158
+ const target = require === 'response' || rootIsAssertion ? root : direct[0];
159
+ const signed = covered.find((element) => element === target);
160
+ if (!signed) {
161
+ return fail('signedNode', `the signature does not cover the ${REQUIRED_LABEL[require]} this validator requires`);
144
162
  }
145
- // 3b. Nothing assertion-shaped outside the one read. Wherever the
163
+ // 3c. Everything below is read from `assertion` and nowhere else: the
164
+ // bare root Assertion, or the Response's single direct-child one —
165
+ // either the signed element itself or, when the Response is signed,
166
+ // inside it.
167
+ const assertion = rootIsAssertion ? root : direct[0];
168
+ // 3d. Nothing assertion-shaped outside the one read. Wherever the
146
169
  // signature sits, the payload travels on whole — Saml2PureProvider hands
147
170
  // it to the cookie provider — so an Assertion or EncryptedAssertion in
148
171
  // an unsigned part of it (Extensions, a sibling, a wrapper) is something
149
- // a later reader may take for the real one.
150
- if (!everyAssertionWithin(doc, assertion)) {
151
- return fail('signedNode', 'the document carries a saml:Assertion or saml:EncryptedAssertion outside the one the signature covers');
172
+ // a later reader may take for the real one. Nor inside a ds:Signature,
173
+ // whose subtree an enveloped signature leaves unsigned.
174
+ const place = placeOfAssertions(doc, assertion);
175
+ if (place === 'inSignature') {
176
+ return fail('signedNode', 'the document carries an Assertion or EncryptedAssertion inside a ds:Signature, where no signature covers it');
177
+ }
178
+ if (place === 'outside') {
179
+ return fail('signedNode', 'the document carries an Assertion or EncryptedAssertion, SAML 2.0 or 1.x, outside the one the signature covers');
152
180
  }
153
181
  // 4. Status. Only when the Response is the signed element: otherwise it
154
182
  // lies outside the signature, and checking a field an attacker sets is
155
183
  // worse than not checking it — it reads like verification.
156
184
  if (require === 'response') {
157
- const status = directChild(root, PROTOCOL_NS, 'Status');
158
- const codeValue = status
159
- ? directChild(status, PROTOCOL_NS, 'StatusCode')?.getAttribute('Value')
160
- : null;
161
- if (!codeValue)
162
- return fail('status', 'the response must carry exactly one samlp:Status holding exactly one StatusCode with a Value');
185
+ const status = requireOne(root, PROTOCOL_NS, 'Status', 'status', 'the response', 'samlp:Status');
186
+ const code = requireOne(status, PROTOCOL_NS, 'StatusCode', 'status', 'the samlp:Status', 'samlp:StatusCode');
187
+ const codeValue = code.getAttribute('Value');
188
+ if (!codeValue) {
189
+ return fail('status', 'the samlp:StatusCode carries no Value');
190
+ }
163
191
  if (codeValue !== SUCCESS) {
164
- return fail('status', `the identity provider declined the login: ${codeValue}`);
192
+ return fail('status', `the identity provider declined the login: ${(0, signedNode_1.quoteUntrusted)(codeValue)}`);
165
193
  }
166
194
  }
167
195
  // 4b. The assertion's own ID.
@@ -170,9 +198,9 @@ function createValidator(require, options) {
170
198
  return fail('assertionId', 'the assertion carries no ID');
171
199
  // 5. The assertion's Issuer — inside the signature either way, so both
172
200
  // validators check it.
173
- const issuer = directChild(assertion, SAML_NS, 'Issuer')?.textContent ?? '';
201
+ const issuer = requireOne(assertion, SAML_NS, 'Issuer', 'issuer', 'the assertion', 'saml:Issuer').textContent ?? '';
174
202
  if (!issuer) {
175
- return fail('issuer', 'the assertion must carry exactly one non-empty saml:Issuer');
203
+ return fail('issuer', "the assertion's saml:Issuer is empty");
176
204
  }
177
205
  // Fail closed: with nothing to compare against, any issuer whose key is
178
206
  // configured would pass, which is not what this validator promises.
@@ -180,7 +208,7 @@ function createValidator(require, options) {
180
208
  return fail('issuer', 'no expectedIssuer was configured, so the assertion issuer cannot be trusted');
181
209
  }
182
210
  if (issuer !== context.expectedIssuer) {
183
- return fail('issuer', `the assertion was issued by ${issuer}, not the trusted issuer`);
211
+ return fail('issuer', `the assertion was issued by ${(0, signedNode_1.quoteUntrusted)(issuer)}, not the trusted issuer`);
184
212
  }
185
213
  // 5b. The cross-check against the Response's Issuer belongs to the
186
214
  // signed-Response validator alone: only there are both inside the
@@ -198,22 +226,24 @@ function createValidator(require, options) {
198
226
  }
199
227
  }
200
228
  // 6, 7, 8. Conditions and their window.
201
- const conditions = directChild(assertion, SAML_NS, 'Conditions');
202
- if (!conditions)
203
- return fail('conditions', 'the assertion must carry exactly one saml:Conditions');
229
+ const conditions = requireOne(assertion, SAML_NS, 'Conditions', 'conditions', 'the assertion', 'saml:Conditions');
204
230
  const notBeforeRaw = conditions.getAttribute('NotBefore');
205
231
  if (notBeforeRaw) {
206
232
  const notBefore = (0, xsdDateTime_1.parseXsdDateTime)(notBeforeRaw);
207
233
  if (!notBefore) {
208
- return fail('notBefore', `Conditions NotBefore is not a valid xsd:dateTime: ${notBeforeRaw}`);
234
+ return fail('notBefore', `Conditions NotBefore is not a valid xsd:dateTime: ${(0, signedNode_1.quoteUntrusted)(notBeforeRaw)}`);
209
235
  }
210
236
  if (notBefore.getTime() - skew > Date.now()) {
211
237
  return fail('notBefore', 'the assertion is not valid yet');
212
238
  }
213
239
  }
214
- const conditionsExpiry = (0, xsdDateTime_1.parseXsdDateTime)(conditions.getAttribute('NotOnOrAfter'));
240
+ const notOnOrAfterRaw = conditions.getAttribute('NotOnOrAfter');
241
+ if (!notOnOrAfterRaw) {
242
+ return fail('notOnOrAfter', 'Conditions carries no NotOnOrAfter, so the assertion states no lifetime');
243
+ }
244
+ const conditionsExpiry = (0, xsdDateTime_1.parseXsdDateTime)(notOnOrAfterRaw);
215
245
  if (!conditionsExpiry) {
216
- return fail('notOnOrAfter', 'Conditions carries no usable NotOnOrAfter, so the assertion states no lifetime');
246
+ return fail('notOnOrAfter', `Conditions NotOnOrAfter is not a valid xsd:dateTime: ${(0, signedNode_1.quoteUntrusted)(notOnOrAfterRaw)}`);
217
247
  }
218
248
  if (conditionsExpiry.getTime() + skew <= Date.now()) {
219
249
  return fail('notOnOrAfter', 'the assertion has expired');
@@ -226,15 +256,16 @@ function createValidator(require, options) {
226
256
  }
227
257
  for (const restriction of restrictions) {
228
258
  const names = directChildren(restriction, SAML_NS, 'Audience').map((a) => a.textContent ?? '');
259
+ if (names.length === 0) {
260
+ return fail('audience', 'an AudienceRestriction names no audience');
261
+ }
229
262
  if (!names.includes(context.audience)) {
230
263
  return fail('audience', 'an AudienceRestriction on this assertion does not name us');
231
264
  }
232
265
  }
233
- // 10. One bearer confirmation satisfying everything together.
266
+ // 10. One bearer confirmation satisfying everything together. It
267
+ // refuses by itself, naming why each candidate failed.
234
268
  const chosen = chooseBearerConfirmation(assertion, context, skew);
235
- if (!chosen) {
236
- return fail('bearerConfirmation', 'no single bearer SubjectConfirmation, under exactly one saml:Subject and with exactly one SubjectConfirmationData, answers our request, names our ACS and is still open');
237
- }
238
269
  // 11. Destination — the signed-Response validator only, for the same
239
270
  // reason as Status. Addressing in the other flow rests on Recipient,
240
271
  // which step 10 required and which sits inside the signed assertion.
@@ -244,7 +275,7 @@ function createValidator(require, options) {
244
275
  return fail('destination', 'the response carries no Destination');
245
276
  }
246
277
  if (destination !== context.acsUrl) {
247
- return fail('destination', `the response is addressed to ${destination}, not to us`);
278
+ return fail('destination', `the response is addressed to ${(0, signedNode_1.quoteUntrusted)(destination)}, not to us`);
248
279
  }
249
280
  }
250
281
  // Expiry: the earlier of the two windows.
@@ -319,73 +350,55 @@ function directChild(parent, ns, local) {
319
350
  return found.length === 1 ? found[0] : null;
320
351
  }
321
352
  /**
322
- * Whether every `saml:Assertion` and `saml:EncryptedAssertion` in the
323
- * document is `assertion` itself or lies inside it.
353
+ * The single direct child with this name, or a refusal that says which way
354
+ * the count failed: absent and more than one are different faults, and a
355
+ * message that cannot tell them apart sends the reader to the wrong one.
324
356
  */
325
- function everyAssertionWithin(doc, assertion) {
326
- for (const local of ['Assertion', 'EncryptedAssertion']) {
327
- const found = doc.getElementsByTagNameNS(SAML_NS, local);
357
+ function requireOne(parent, ns, local, check, holder, label) {
358
+ const found = directChildren(parent, ns, local);
359
+ if (found.length === 0)
360
+ return fail(check, `${holder} carries no ${label}`);
361
+ if (found.length > 1) {
362
+ return fail(check, `${holder} carries ${found.length} ${label}; exactly one is allowed`);
363
+ }
364
+ return found[0];
365
+ }
366
+ /**
367
+ * Walks up from every assertion-shaped element. Reaching the assertion that
368
+ * was read means it is inside it — unless a ds:Signature came first: an
369
+ * enveloped signature leaves its own subtree out of the digest, so anything
370
+ * there is unsigned, however deep inside the signed assertion it sits.
371
+ */
372
+ function placeOfAssertions(doc, assertion) {
373
+ for (const [ns, local] of ASSERTION_SHAPED) {
374
+ const found = doc.getElementsByTagNameNS(ns, local);
328
375
  for (let i = 0; i < found.length; i++) {
329
376
  let node = found[i];
330
377
  while (node && node !== assertion) {
378
+ if (node.localName === 'Signature' && node.namespaceURI === DSIG_NS) {
379
+ return 'inSignature';
380
+ }
331
381
  node = node.parentNode;
332
382
  }
333
383
  if (!node)
334
- return false;
384
+ return 'outside';
335
385
  }
336
386
  }
337
- return true;
338
- }
339
- /**
340
- * The assertion the signature covers, or null when the signed element is not
341
- * one and does not contain exactly one.
342
- *
343
- * "Exactly one" matters: a signed Response wrapping two assertions leaves
344
- * "which did we verify" ambiguous, which is the wrapping question again.
345
- */
346
- function assertionInside(signed, root, require) {
347
- // The signature must cover what this validator was built to require. A
348
- // signed-Response validator handed an assertion-signed document refuses
349
- // here, and vice versa — that refusal is the whole point of shipping two.
350
- const signedIsResponse = signed.localName === 'Response' && signed.namespaceURI === PROTOCOL_NS;
351
- const signedIsAssertion = signed.localName === 'Assertion' && signed.namespaceURI === SAML_NS;
352
- if (require === 'response' && !signedIsResponse)
353
- return null;
354
- if (require === 'assertion' && !signedIsAssertion)
355
- return null;
356
- // A bare Assertion is its own document: the only assertion there is, and
357
- // it must be the element signed.
358
- if (root.localName === 'Assertion' && root.namespaceURI === SAML_NS) {
359
- return signed === root ? root : null;
360
- }
361
- // Whatever was signed, the response must carry exactly one assertion.
362
- //
363
- // Reading only from the signed element is not enough: Saml2PureProvider
364
- // hands the whole response to the cookie provider, which reads whatever is
365
- // in it. (toBearerAssertion refuses a second assertion on the bearer path
366
- // too, but the validator does not lean on its caller.) A forged assertion
367
- // placed beside the signed one must therefore end the login, not merely be
368
- // ignored here.
369
- const assertions = directChildren(root, SAML_NS, 'Assertion');
370
- if (assertions.length !== 1)
371
- return null;
372
- const only = assertions[0];
373
- if (signed.localName === 'Assertion' && signed.namespaceURI === SAML_NS) {
374
- return signed === only ? only : null;
375
- }
376
- if (signed.localName === 'Response' && signed.namespaceURI === PROTOCOL_NS) {
377
- return signed === root ? only : null;
378
- }
379
- return null;
387
+ return 'within';
380
388
  }
389
+ /** How many candidates a bearerConfirmation refusal names before "and N more". */
390
+ const LISTED_CANDIDATES = 5;
381
391
  /**
382
- * The bearer confirmation this login may rely on.
392
+ * The bearer confirmation this login may rely on, or a refusal naming why
393
+ * each candidate failed.
383
394
  *
384
395
  * Every part must hold on the **same** element: gathering `InResponseTo` from
385
396
  * one confirmation and `Recipient` from another is how a document satisfies a
386
- * check nothing in it actually satisfies. When several qualify — which a real
387
- * identity provider does not produce — the earliest window wins, so the
388
- * outcome is a shorter session rather than a longer one.
397
+ * check nothing in it actually satisfies. It is existential: one candidate
398
+ * passing every sub-rule is enough, and every candidate is evaluated, so a
399
+ * failing one never hides a valid one after it. When several qualify — which
400
+ * a real identity provider does not produce — the earliest window wins, so
401
+ * the outcome is a shorter session rather than a longer one.
389
402
  *
390
403
  * `latestNotOnOrAfter` answers a different question: until when could some
391
404
  * confirmation let this assertion in? It is the latest `NotOnOrAfter` among
@@ -394,51 +407,113 @@ function assertionInside(signed, root, require) {
394
407
  * retention needs that bound, not the session's; see the caller.
395
408
  */
396
409
  function chooseBearerConfirmation(assertion, context, skew) {
410
+ const subject = requireOne(assertion, SAML_NS, 'Subject', 'bearerConfirmation', 'the assertion', 'saml:Subject');
411
+ const confirmations = directChildren(subject, SAML_NS, 'SubjectConfirmation');
412
+ if (confirmations.length === 0) {
413
+ return fail('bearerConfirmation', 'the saml:Subject holds no SubjectConfirmation');
414
+ }
397
415
  const now = Date.now();
398
416
  let best = null;
399
417
  let latest = null;
400
- const subject = directChild(assertion, SAML_NS, 'Subject');
401
- if (!subject)
402
- return null;
403
- for (const confirmation of directChildren(subject, SAML_NS, 'SubjectConfirmation')) {
404
- if (confirmation.getAttribute('Method') !== BEARER)
405
- continue;
406
- const data = directChild(confirmation, SAML_NS, 'SubjectConfirmationData');
407
- if (!data)
418
+ const reasons = [];
419
+ for (const confirmation of confirmations) {
420
+ const candidate = readConfirmation(confirmation, context);
421
+ if ('reason' in candidate) {
422
+ reasons.push(candidate.reason);
408
423
  continue;
409
- // Option B: an expected ID must be matched exactly; no expected ID — an
410
- // IdP-initiated login — means the attribute must not be there at all.
411
- if (context.expectedInResponseTo === undefined) {
412
- if (data.hasAttribute('InResponseTo'))
413
- continue;
414
424
  }
415
- else if (data.getAttribute('InResponseTo') !== context.expectedInResponseTo) {
416
- continue;
417
- }
418
- if (data.getAttribute('Recipient') !== context.acsUrl)
419
- continue;
420
- const notOnOrAfter = (0, xsdDateTime_1.parseXsdDateTime)(data.getAttribute('NotOnOrAfter'));
421
- if (!notOnOrAfter)
422
- continue;
423
- const notBeforeRaw = data.getAttribute('NotBefore');
424
- const notBefore = notBeforeRaw ? (0, xsdDateTime_1.parseXsdDateTime)(notBeforeRaw) : null;
425
- if (notBeforeRaw && !notBefore)
426
- continue;
425
+ const { notOnOrAfter, notBefore } = candidate;
427
426
  // Could qualify at some instant: counts towards how long to remember.
428
427
  if (!latest || notOnOrAfter.getTime() > latest.getTime()) {
429
428
  latest = notOnOrAfter;
430
429
  }
431
- // Qualifies now: a candidate for the session's window.
432
- if (notOnOrAfter.getTime() + skew <= now)
430
+ // 7, 8. Qualifies now: a candidate for the session's window.
431
+ if (notOnOrAfter.getTime() + skew <= now) {
432
+ reasons.push('NotOnOrAfter has passed');
433
433
  continue;
434
- if (notBefore && notBefore.getTime() - skew > now)
434
+ }
435
+ if (notBefore && notBefore.getTime() - skew > now) {
436
+ reasons.push('NotBefore has not arrived');
435
437
  continue;
438
+ }
436
439
  if (!best || notOnOrAfter.getTime() < best.getTime())
437
440
  best = notOnOrAfter;
438
441
  }
439
442
  // `latest` is set whenever `best` is: every qualifying confirmation was
440
- // counted towards it first.
441
- return best && latest
442
- ? { notOnOrAfter: best, latestNotOnOrAfter: latest }
443
- : null;
443
+ // counted towards it first. When nothing qualified, every candidate left
444
+ // exactly one reason, in document order.
445
+ if (best && latest)
446
+ return { notOnOrAfter: best, latestNotOnOrAfter: latest };
447
+ return fail('bearerConfirmation', describeRefusals(reasons));
448
+ }
449
+ /**
450
+ * Sub-rules 1 to 6, in the spec's fixed order: the first one this candidate
451
+ * fails, or the window it states. The temporal sub-rules 7 and 8 are the
452
+ * caller's, since a candidate failing only those still bounds replay
453
+ * retention.
454
+ */
455
+ function readConfirmation(confirmation, context) {
456
+ // 1.
457
+ if (confirmation.getAttribute('Method') !== BEARER) {
458
+ return { reason: 'Method is not bearer' };
459
+ }
460
+ // 2.
461
+ const data = directChildren(confirmation, SAML_NS, 'SubjectConfirmationData');
462
+ if (data.length === 0) {
463
+ return { reason: 'carries no SubjectConfirmationData' };
464
+ }
465
+ if (data.length > 1) {
466
+ return {
467
+ reason: `carries ${data.length} SubjectConfirmationData; exactly one is allowed`,
468
+ };
469
+ }
470
+ const only = data[0];
471
+ // 3. Option B: an expected ID must be matched exactly; no expected ID — an
472
+ // IdP-initiated login — means the attribute must not be there at all.
473
+ if (context.expectedInResponseTo === undefined) {
474
+ if (only.hasAttribute('InResponseTo')) {
475
+ return {
476
+ reason: 'InResponseTo is present, but this login sent no request',
477
+ };
478
+ }
479
+ }
480
+ else if (only.getAttribute('InResponseTo') !== context.expectedInResponseTo) {
481
+ return { reason: 'InResponseTo does not answer our request' };
482
+ }
483
+ // 4.
484
+ if (only.getAttribute('Recipient') !== context.acsUrl) {
485
+ return { reason: 'Recipient is not the ACS' };
486
+ }
487
+ // 5.
488
+ const notOnOrAfterRaw = only.getAttribute('NotOnOrAfter');
489
+ if (!notOnOrAfterRaw) {
490
+ return { reason: 'SubjectConfirmationData has no NotOnOrAfter' };
491
+ }
492
+ const notOnOrAfter = (0, xsdDateTime_1.parseXsdDateTime)(notOnOrAfterRaw);
493
+ if (!notOnOrAfter) {
494
+ return {
495
+ reason: 'SubjectConfirmationData NotOnOrAfter is not a valid xsd:dateTime',
496
+ };
497
+ }
498
+ // 6.
499
+ const notBeforeRaw = only.getAttribute('NotBefore');
500
+ const notBefore = notBeforeRaw ? (0, xsdDateTime_1.parseXsdDateTime)(notBeforeRaw) : null;
501
+ if (notBeforeRaw && !notBefore) {
502
+ return {
503
+ reason: 'SubjectConfirmationData NotBefore is not a valid xsd:dateTime',
504
+ };
505
+ }
506
+ return { notOnOrAfter, notBefore };
507
+ }
508
+ /**
509
+ * `no bearer confirmation qualifies: #1 …; #2 …`, naming at most
510
+ * LISTED_CANDIDATES candidates so the message stays bounded however many the
511
+ * document carries.
512
+ */
513
+ function describeRefusals(reasons) {
514
+ const listed = reasons
515
+ .slice(0, LISTED_CANDIDATES)
516
+ .map((reason, index) => `#${index + 1} ${reason}`);
517
+ const more = reasons.length - listed.length;
518
+ return `no bearer confirmation qualifies: ${listed.join('; ')}${more > 0 ? `; and ${more} more` : ''}`;
444
519
  }
@@ -30,8 +30,8 @@ import type { Document, Element } from '@xmldom/xmldom';
30
30
  */
31
31
  export declare function toPem(certificate: string): string;
32
32
  /**
33
- * A value taken from the document before any signature has been verified,
34
- * made safe to put in a message: JSON-quoted, so a newline smuggled in as
33
+ * Quotes a value from the document, or a message quoting one, before
34
+ * interpolating it into a refusal: JSON-quoted, so a newline smuggled in as
35
35
  * `&#10;` shows as `\n` rather than forging a line in a log, and cut to 64
36
36
  * characters, so an attacker cannot fill a log with it.
37
37
  */
@@ -56,8 +56,8 @@ function toPem(certificate) {
56
56
  return pem;
57
57
  }
58
58
  /**
59
- * A value taken from the document before any signature has been verified,
60
- * made safe to put in a message: JSON-quoted, so a newline smuggled in as
59
+ * Quotes a value from the document, or a message quoting one, before
60
+ * interpolating it into a refusal: JSON-quoted, so a newline smuggled in as
61
61
  * `&#10;` shows as `\n` rather than forging a line in a log, and cut to 64
62
62
  * characters, so an attacker cannot fill a log with it.
63
63
  */
@@ -111,7 +111,15 @@ function resolveOne(xml, doc, signatureNode, certificates) {
111
111
  publicCert: certificate,
112
112
  getCertFromKeyInfo: () => null,
113
113
  });
114
- verifier.loadSignature(signatureNode);
114
+ // loadSignature throws for a malformed Signature, and xml-crypto's
115
+ // message embeds the offending element: document text, so quoted.
116
+ try {
117
+ verifier.loadSignature(signatureNode);
118
+ }
119
+ catch (error) {
120
+ const message = error instanceof Error ? error.message : String(error);
121
+ throw new Error(`the signature element is malformed: ${quoteUntrusted(message)}`);
122
+ }
115
123
  try {
116
124
  // Returns false for a digest mismatch and throws when the signature
117
125
  // value itself fails. Both mean "not this certificate".
@@ -131,8 +139,11 @@ function resolveOne(xml, doc, signatureNode, certificates) {
131
139
  // reference: two would be two candidate answers to "what is signed", the
132
140
  // ambiguity this module exists to remove.
133
141
  const references = signatureNode.getElementsByTagNameNS(DSIG_NS, 'Reference');
134
- if (references.length !== 1) {
135
- throw new Error(`the signature carries ${references.length} references; exactly one is required`);
142
+ if (references.length === 0) {
143
+ throw new Error('the signature carries no ds:Reference');
144
+ }
145
+ if (references.length > 1) {
146
+ throw new Error(`the signature carries ${references.length} ds:Reference; exactly one is allowed`);
136
147
  }
137
148
  const uri = references[0].getAttribute('URI') ?? '';
138
149
  let referenced = null;
package/package.json CHANGED
@@ -1,12 +1,11 @@
1
1
  {
2
2
  "name": "@mcp-abap-adt/auth-providers",
3
- "version": "4.0.0",
3
+ "version": "4.1.0",
4
4
  "description": "Token providers for MCP ABAP ADT auth-broker",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
7
  "files": [
8
8
  "dist",
9
- "bin",
10
9
  "README.md",
11
10
  "CHANGELOG.md",
12
11
  "LICENSE",
@@ -38,10 +37,6 @@
38
37
  "publishConfig": {
39
38
  "access": "public"
40
39
  },
41
- "bin": {
42
- "auth-authorization-code": "./bin/auth-authorization-code.ts",
43
- "auth-client-credentials": "./bin/auth-client-credentials.ts"
44
- },
45
40
  "scripts": {
46
41
  "chrono": "./tools/version-stats.sh",
47
42
  "clean": "rm -rf dist tsconfig.tsbuildinfo",
@@ -62,7 +57,7 @@
62
57
  "node": "^22 || ^24"
63
58
  },
64
59
  "dependencies": {
65
- "@mcp-abap-adt/interfaces-auth": "^2.0.0",
60
+ "@mcp-abap-adt/interfaces-auth": "^2.0.1",
66
61
  "@mcp-abap-adt/interfaces-auth-sap": "^1.0.1",
67
62
  "@mcp-abap-adt/interfaces-utils": "^1.1.0",
68
63
  "@xmldom/xmldom": "^0.9.12",
@@ -87,7 +82,6 @@
87
82
  "pino": "^10.3.1",
88
83
  "pino-pretty": "^13.1.3",
89
84
  "ts-jest": "^29.2.5",
90
- "tsx": "^4.19.2",
91
85
  "typescript": "^5.9.2"
92
86
  }
93
87
  }