@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.
- package/CHANGELOG.md +82 -0
- package/README.md +179 -26
- package/dist/auth/samlBearerAssertion.d.ts.map +1 -1
- package/dist/auth/samlBearerAssertion.js +4 -1
- package/dist/providers/saml2Utils.d.ts +3 -2
- package/dist/providers/saml2Utils.d.ts.map +1 -1
- package/dist/providers/saml2Utils.js +8 -0
- package/dist/validation/assertionValidator.d.ts.map +1 -1
- package/dist/validation/assertionValidator.js +207 -132
- package/dist/validation/signedNode.d.ts +2 -2
- package/dist/validation/signedNode.js +16 -5
- package/package.json +2 -8
- package/bin/auth-authorization-code.ts +0 -147
- package/bin/auth-client-credentials.ts +0 -109
- package/bin/utils/parseConfig.ts +0 -270
|
@@ -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
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
:
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
151
|
-
|
|
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 =
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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 =
|
|
201
|
+
const issuer = requireOne(assertion, SAML_NS, 'Issuer', 'issuer', 'the assertion', 'saml:Issuer').textContent ?? '';
|
|
174
202
|
if (!issuer) {
|
|
175
|
-
return fail('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 =
|
|
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
|
|
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',
|
|
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
|
-
*
|
|
323
|
-
*
|
|
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
|
|
326
|
-
|
|
327
|
-
|
|
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
|
|
384
|
+
return 'outside';
|
|
335
385
|
}
|
|
336
386
|
}
|
|
337
|
-
return
|
|
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.
|
|
387
|
-
*
|
|
388
|
-
*
|
|
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
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
:
|
|
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
|
-
*
|
|
34
|
-
*
|
|
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
|
* ` ` 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
|
-
*
|
|
60
|
-
*
|
|
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
|
* ` ` 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
|
-
|
|
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
|
|
135
|
-
throw new Error(
|
|
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.
|
|
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.
|
|
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
|
}
|