@mcp-abap-adt/auth-providers 3.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +187 -0
  2. package/README.md +773 -77
  3. package/dist/auth/saml2Auth.d.ts +6 -2
  4. package/dist/auth/saml2Auth.d.ts.map +1 -1
  5. package/dist/auth/saml2Auth.js +9 -20
  6. package/dist/auth/samlBearerAssertion.d.ts.map +1 -1
  7. package/dist/auth/samlBearerAssertion.js +6 -2
  8. package/dist/auth/strictXml.d.ts +13 -0
  9. package/dist/auth/strictXml.d.ts.map +1 -0
  10. package/dist/auth/strictXml.js +21 -0
  11. package/dist/errors/AssertionValidationError.d.ts +15 -0
  12. package/dist/errors/AssertionValidationError.d.ts.map +1 -0
  13. package/dist/errors/AssertionValidationError.js +24 -0
  14. package/dist/errors/TokenProviderErrors.d.ts +2 -0
  15. package/dist/errors/TokenProviderErrors.d.ts.map +1 -1
  16. package/dist/errors/TokenProviderErrors.js +3 -1
  17. package/dist/index.d.ts +3 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +11 -1
  20. package/dist/providers/Saml2BearerProvider.d.ts +1 -0
  21. package/dist/providers/Saml2BearerProvider.d.ts.map +1 -1
  22. package/dist/providers/Saml2BearerProvider.js +17 -2
  23. package/dist/providers/Saml2PureProvider.d.ts +1 -0
  24. package/dist/providers/Saml2PureProvider.d.ts.map +1 -1
  25. package/dist/providers/Saml2PureProvider.js +19 -5
  26. package/dist/providers/saml2Utils.d.ts +50 -2
  27. package/dist/providers/saml2Utils.d.ts.map +1 -1
  28. package/dist/providers/saml2Utils.js +102 -2
  29. package/dist/validation/assertionValidator.d.ts +28 -0
  30. package/dist/validation/assertionValidator.d.ts.map +1 -0
  31. package/dist/validation/assertionValidator.js +519 -0
  32. package/dist/validation/documentIds.d.ts +15 -0
  33. package/dist/validation/documentIds.d.ts.map +1 -0
  34. package/dist/validation/documentIds.js +32 -0
  35. package/dist/validation/inMemoryReplayStore.d.ts +22 -0
  36. package/dist/validation/inMemoryReplayStore.d.ts.map +1 -0
  37. package/dist/validation/inMemoryReplayStore.js +49 -0
  38. package/dist/validation/signedNode.d.ts +54 -0
  39. package/dist/validation/signedNode.d.ts.map +1 -0
  40. package/dist/validation/signedNode.js +182 -0
  41. package/dist/validation/xsdDateTime.d.ts +17 -0
  42. package/dist/validation/xsdDateTime.d.ts.map +1 -0
  43. package/dist/validation/xsdDateTime.js +67 -0
  44. package/package.json +5 -10
  45. package/bin/auth-authorization-code.ts +0 -147
  46. package/bin/auth-client-credentials.ts +0 -109
  47. package/bin/utils/parseConfig.ts +0 -270
@@ -0,0 +1,519 @@
1
+ "use strict";
2
+ /**
3
+ * The shipped assertion validator: the spec's check table, in order.
4
+ *
5
+ * Two properties matter more than any individual check. First, the signature
6
+ * is resolved to an element and every assertion-level field is read *from that
7
+ * element* — a document holding a validly signed fragment beside a forged one
8
+ * is the wrapping attack, and reading the wrong node is how it succeeds.
9
+ * Second, no two refusals share a distinguishing phrase, so a test cannot pass
10
+ * for a neighbouring check's reason.
11
+ *
12
+ * Three fields live on the Response rather than the assertion: Status,
13
+ * Response/Issuer and Destination. The signed-Response validator reads them,
14
+ * because there they are inside the signature. The assertion-only validator
15
+ * does not read them at all — not weakly. That is safe because a declined
16
+ * login carries no assertion, so flipping Status buys an attacker nothing they
17
+ * can sign, and addressing rests on Recipient inside the signed assertion.
18
+ */
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.createSignedAssertionValidator = exports.createSignedResponseValidator = void 0;
21
+ exports.isShippedValidator = isShippedValidator;
22
+ const xmldom_1 = require("@xmldom/xmldom");
23
+ const strictXml_1 = require("../auth/strictXml");
24
+ const AssertionValidationError_1 = require("../errors/AssertionValidationError");
25
+ const documentIds_1 = require("./documentIds");
26
+ const inMemoryReplayStore_1 = require("./inMemoryReplayStore");
27
+ const signedNode_1 = require("./signedNode");
28
+ const xsdDateTime_1 = require("./xsdDateTime");
29
+ const SAML_NS = 'urn:oasis:names:tc:SAML:2.0:assertion';
30
+ const PROTOCOL_NS = 'urn:oasis:names:tc:SAML:2.0:protocol';
31
+ const BEARER = 'urn:oasis:names:tc:SAML:2.0:cm:bearer';
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
+ };
49
+ /**
50
+ * Marks a validator as one of the two shipped here. Module-private and
51
+ * non-enumerable, so it is neither part of the public surface nor visible to
52
+ * a consumer spreading or serialising the object.
53
+ *
54
+ * It exists for one reason: a shipped validator fails closed without
55
+ * `expectedIssuer`, so a provider handed one must insist on `idpEntityId` at
56
+ * construction — otherwise the mistake surfaces only as an `issuer` refusal
57
+ * after a human has finished a browser login. A custom validator carries no
58
+ * brand and may establish trust however it likes.
59
+ */
60
+ const SHIPPED = Symbol('mcp-abap-adt.shippedAssertionValidator');
61
+ function brand(validator) {
62
+ Object.defineProperty(validator, SHIPPED, {
63
+ value: true,
64
+ enumerable: false,
65
+ });
66
+ return validator;
67
+ }
68
+ /** Whether this validator came from one of the two shipped factories. */
69
+ function isShippedValidator(validator) {
70
+ return validator[SHIPPED] === true;
71
+ }
72
+ const createSignedResponseValidator = (options) => brand(createValidator('response', options));
73
+ exports.createSignedResponseValidator = createSignedResponseValidator;
74
+ const createSignedAssertionValidator = (options) => brand(createValidator('assertion', options));
75
+ exports.createSignedAssertionValidator = createSignedAssertionValidator;
76
+ function createValidator(require, options) {
77
+ const skew = options.clockSkewMs ?? 0;
78
+ if (!Number.isInteger(skew) || skew < 0) {
79
+ throw new Error(`clockSkewMs must be a finite non-negative integer, got ${String(options.clockSkewMs)}`);
80
+ }
81
+ if (options.idpCertificates.length === 0) {
82
+ throw new Error('idpCertificates must not be empty: nothing could be verified');
83
+ }
84
+ // Normalised and proved here, once, rather than inside verification. Three
85
+ // reasons, and the last bites hardest: a malformed entry standing first in
86
+ // the list would abort the rotation loop before a later valid certificate
87
+ // was tried; a constructor is where this package already refuses a bad
88
+ // configuration; and a login happens after a human has used a browser, so a
89
+ // formatting mistake found then wastes their work, not ours.
90
+ const certificates = options.idpCertificates.map(signedNode_1.toPem);
91
+ const store = options.replayStore ?? inMemoryReplayStore_1.defaultReplayStore;
92
+ return {
93
+ async validate(samlResponse, context) {
94
+ // 1. Parses, and the document element is a samlp:Response.
95
+ const xml = Buffer.from(samlResponse, 'base64').toString('utf8');
96
+ // No DTD, ever. A SAML message has no use for one, and a DOCTYPE is
97
+ // where parsers diverge — entity expansion, internal subsets — and this
98
+ // document is parsed twice: by @xmldom/xmldom 0.9 here and by the 0.8
99
+ // nested inside xml-crypto. Refused before either parse is trusted.
100
+ if (/<!DOCTYPE/i.test(xml)) {
101
+ return fail('document', 'the SAMLResponse carries a DOCTYPE declaration, which is never accepted');
102
+ }
103
+ let doc;
104
+ try {
105
+ doc = (0, strictXml_1.parseStrictXml)(xml);
106
+ }
107
+ catch {
108
+ return fail('document', 'the SAMLResponse did not parse as XML');
109
+ }
110
+ const root = doc.documentElement;
111
+ if (!root)
112
+ return fail('document', 'the SAMLResponse did not parse as XML');
113
+ const rootIsResponse = root.localName === 'Response' && root.namespaceURI === PROTOCOL_NS;
114
+ const rootIsAssertion = root.localName === 'Assertion' && root.namespaceURI === SAML_NS;
115
+ // A bare Assertion is a document only the assertion-only validator
116
+ // accepts: the saml2-bearer grant exchanges an Assertion, and 3.0.0
117
+ // already takes one as Saml2BearerProvider's payload.
118
+ if (!rootIsResponse && !(require === 'assertion' && rootIsAssertion)) {
119
+ return fail('document', require === 'assertion'
120
+ ? `expected a samlp:Response or a saml:Assertion, got ${(0, signedNode_1.quoteUntrusted)(root.localName ?? '')}`
121
+ : `expected the document element to be a samlp:Response, got ${(0, signedNode_1.quoteUntrusted)(root.localName ?? '')}`);
122
+ }
123
+ // 1b. Unique IDs, before any reference is resolved.
124
+ const duplicate = (0, documentIds_1.findDuplicateId)(doc);
125
+ if (duplicate) {
126
+ return fail('duplicateId', `the document uses the ID ${(0, signedNode_1.quoteUntrusted)(duplicate)} more than once, so which element is signed is ambiguous`);
127
+ }
128
+ // 2 + 3. Verify every signature, then take the element this validator
129
+ // requires from among those they cover. A response signed at both
130
+ // levels — as many identity providers send — satisfies either validator.
131
+ let covered;
132
+ try {
133
+ covered = (0, signedNode_1.resolveSignedElements)(xml, doc, certificates);
134
+ }
135
+ catch (error) {
136
+ return fail('signature', error.message);
137
+ }
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`);
162
+ }
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
169
+ // signature sits, the payload travels on whole — Saml2PureProvider hands
170
+ // it to the cookie provider — so an Assertion or EncryptedAssertion in
171
+ // an unsigned part of it (Extensions, a sibling, a wrapper) is something
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');
180
+ }
181
+ // 4. Status. Only when the Response is the signed element: otherwise it
182
+ // lies outside the signature, and checking a field an attacker sets is
183
+ // worse than not checking it — it reads like verification.
184
+ if (require === 'response') {
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
+ }
191
+ if (codeValue !== SUCCESS) {
192
+ return fail('status', `the identity provider declined the login: ${(0, signedNode_1.quoteUntrusted)(codeValue)}`);
193
+ }
194
+ }
195
+ // 4b. The assertion's own ID.
196
+ const assertionId = (0, documentIds_1.readRequiredId)(assertion);
197
+ if (!assertionId)
198
+ return fail('assertionId', 'the assertion carries no ID');
199
+ // 5. The assertion's Issuer — inside the signature either way, so both
200
+ // validators check it.
201
+ const issuer = requireOne(assertion, SAML_NS, 'Issuer', 'issuer', 'the assertion', 'saml:Issuer').textContent ?? '';
202
+ if (!issuer) {
203
+ return fail('issuer', "the assertion's saml:Issuer is empty");
204
+ }
205
+ // Fail closed: with nothing to compare against, any issuer whose key is
206
+ // configured would pass, which is not what this validator promises.
207
+ if (!context.expectedIssuer) {
208
+ return fail('issuer', 'no expectedIssuer was configured, so the assertion issuer cannot be trusted');
209
+ }
210
+ if (issuer !== context.expectedIssuer) {
211
+ return fail('issuer', `the assertion was issued by ${(0, signedNode_1.quoteUntrusted)(issuer)}, not the trusted issuer`);
212
+ }
213
+ // 5b. The cross-check against the Response's Issuer belongs to the
214
+ // signed-Response validator alone: only there are both inside the
215
+ // signature.
216
+ if (require === 'response') {
217
+ // Optional, so none is fine; but two are an ambiguity, and one that is
218
+ // present must agree — empty included, since empty is not absent.
219
+ const responseIssuers = directChildren(root, SAML_NS, 'Issuer');
220
+ if (responseIssuers.length > 1) {
221
+ return fail('issuer', 'the response must carry at most one saml:Issuer');
222
+ }
223
+ if (responseIssuers.length === 1 &&
224
+ (responseIssuers[0].textContent ?? '') !== issuer) {
225
+ return fail('issuer', 'the response and the assertion name different issuers');
226
+ }
227
+ }
228
+ // 6, 7, 8. Conditions and their window.
229
+ const conditions = requireOne(assertion, SAML_NS, 'Conditions', 'conditions', 'the assertion', 'saml:Conditions');
230
+ const notBeforeRaw = conditions.getAttribute('NotBefore');
231
+ if (notBeforeRaw) {
232
+ const notBefore = (0, xsdDateTime_1.parseXsdDateTime)(notBeforeRaw);
233
+ if (!notBefore) {
234
+ return fail('notBefore', `Conditions NotBefore is not a valid xsd:dateTime: ${(0, signedNode_1.quoteUntrusted)(notBeforeRaw)}`);
235
+ }
236
+ if (notBefore.getTime() - skew > Date.now()) {
237
+ return fail('notBefore', 'the assertion is not valid yet');
238
+ }
239
+ }
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);
245
+ if (!conditionsExpiry) {
246
+ return fail('notOnOrAfter', `Conditions NotOnOrAfter is not a valid xsd:dateTime: ${(0, signedNode_1.quoteUntrusted)(notOnOrAfterRaw)}`);
247
+ }
248
+ if (conditionsExpiry.getTime() + skew <= Date.now()) {
249
+ return fail('notOnOrAfter', 'the assertion has expired');
250
+ }
251
+ // 9. Every AudienceRestriction must name us; several Audience inside one
252
+ // are alternatives.
253
+ const restrictions = directChildren(conditions, SAML_NS, 'AudienceRestriction');
254
+ if (restrictions.length === 0) {
255
+ return fail('audience', 'the assertion restricts no audience');
256
+ }
257
+ for (const restriction of restrictions) {
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
+ }
262
+ if (!names.includes(context.audience)) {
263
+ return fail('audience', 'an AudienceRestriction on this assertion does not name us');
264
+ }
265
+ }
266
+ // 10. One bearer confirmation satisfying everything together. It
267
+ // refuses by itself, naming why each candidate failed.
268
+ const chosen = chooseBearerConfirmation(assertion, context, skew);
269
+ // 11. Destination — the signed-Response validator only, for the same
270
+ // reason as Status. Addressing in the other flow rests on Recipient,
271
+ // which step 10 required and which sits inside the signed assertion.
272
+ if (require === 'response') {
273
+ const destination = root.getAttribute('Destination');
274
+ if (!destination) {
275
+ return fail('destination', 'the response carries no Destination');
276
+ }
277
+ if (destination !== context.acsUrl) {
278
+ return fail('destination', `the response is addressed to ${(0, signedNode_1.quoteUntrusted)(destination)}, not to us`);
279
+ }
280
+ }
281
+ // Expiry: the earlier of the two windows.
282
+ const expiresAt = new Date(Math.min(conditionsExpiry.getTime(), chosen.notOnOrAfter.getTime()));
283
+ // 12. Replay. Retention is NOT expiresAt: it must last for as long as
284
+ // this validator could accept the assertion again. Conditions bound
285
+ // that, but so does the LATEST bearer confirmation that can qualify —
286
+ // with confirmations closing at +120 s and +600 s the session ends at
287
+ // +120 s, yet at +200 s the second one still qualifies, and an entry
288
+ // dropped at +120 s would let the same assertion in a second time. The
289
+ // skew is added on top, since inside it the assertion is still accepted.
290
+ const retainUntil = new Date(Math.min(conditionsExpiry.getTime(), chosen.latestNotOnOrAfter.getTime()) + skew);
291
+ const fresh = await store.recordIfUnseen({ issuer, assertionId }, retainUntil);
292
+ if (!fresh) {
293
+ return fail('replay', 'this assertion has been presented before');
294
+ }
295
+ return {
296
+ expiresAt,
297
+ assertionId,
298
+ issuer,
299
+ nameId: (() => {
300
+ // Subject, then NameID — no `?? assertion` fallback, which would
301
+ // read a NameID from outside the Subject when the Subject is absent.
302
+ const subject = directChild(assertion, SAML_NS, 'Subject');
303
+ return subject
304
+ ? (directChild(subject, SAML_NS, 'NameID')?.textContent ??
305
+ undefined)
306
+ : undefined;
307
+ })(),
308
+ raw: samlResponse,
309
+ // The signed element, not the response: this is what a consumer may
310
+ // parse without re-deriving what the signature covered.
311
+ signedXml: new xmldom_1.XMLSerializer().serializeToString(signed),
312
+ };
313
+ },
314
+ };
315
+ }
316
+ function fail(check, message) {
317
+ throw new AssertionValidationError_1.AssertionValidationError(check, message);
318
+ }
319
+ /**
320
+ * Direct children with this namespace and local name — **not** descendants.
321
+ *
322
+ * `getElementsByTagNameNS` searches the whole subtree, and that is the wrong
323
+ * tool for a structural path. An assertion with no `Conditions` of its own but
324
+ * a `Conditions` buried somewhere inside it would answer the descendant search
325
+ * and satisfy a check it does not meet; the same trick works for `Issuer`,
326
+ * `Status` and `Subject`. Each segment of a SAML path is therefore walked
327
+ * explicitly, one level at a time.
328
+ */
329
+ function directChildren(parent, ns, local) {
330
+ const out = [];
331
+ const nodes = parent.childNodes;
332
+ for (let i = 0; i < nodes.length; i++) {
333
+ const node = nodes[i];
334
+ // nodeType 1 is ELEMENT_NODE; the constant is unavailable without the dom
335
+ // lib, which this project deliberately does not use.
336
+ if (node.nodeType === 1 &&
337
+ node.namespaceURI === ns &&
338
+ node.localName === local) {
339
+ out.push(node);
340
+ }
341
+ }
342
+ return out;
343
+ }
344
+ /** The single direct child with this name, or null when there is not exactly one. */
345
+ function directChild(parent, ns, local) {
346
+ const found = directChildren(parent, ns, local);
347
+ // Not "the first": two siblings sharing a name is an ambiguity, and
348
+ // resolving it silently in favour of the first is how a forged element comes
349
+ // to be read in preference to a real one.
350
+ return found.length === 1 ? found[0] : null;
351
+ }
352
+ /**
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.
356
+ */
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);
375
+ for (let i = 0; i < found.length; i++) {
376
+ let node = found[i];
377
+ while (node && node !== assertion) {
378
+ if (node.localName === 'Signature' && node.namespaceURI === DSIG_NS) {
379
+ return 'inSignature';
380
+ }
381
+ node = node.parentNode;
382
+ }
383
+ if (!node)
384
+ return 'outside';
385
+ }
386
+ }
387
+ return 'within';
388
+ }
389
+ /** How many candidates a bearerConfirmation refusal names before "and N more". */
390
+ const LISTED_CANDIDATES = 5;
391
+ /**
392
+ * The bearer confirmation this login may rely on, or a refusal naming why
393
+ * each candidate failed.
394
+ *
395
+ * Every part must hold on the **same** element: gathering `InResponseTo` from
396
+ * one confirmation and `Recipient` from another is how a document satisfies a
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.
402
+ *
403
+ * `latestNotOnOrAfter` answers a different question: until when could some
404
+ * confirmation let this assertion in? It is the latest `NotOnOrAfter` among
405
+ * the confirmations that satisfy every non-temporal part — including one whose
406
+ * `NotBefore` has not arrived yet, since it qualifies once it does. Replay
407
+ * retention needs that bound, not the session's; see the caller.
408
+ */
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
+ }
415
+ const now = Date.now();
416
+ let best = null;
417
+ let latest = null;
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);
423
+ continue;
424
+ }
425
+ const { notOnOrAfter, notBefore } = candidate;
426
+ // Could qualify at some instant: counts towards how long to remember.
427
+ if (!latest || notOnOrAfter.getTime() > latest.getTime()) {
428
+ latest = notOnOrAfter;
429
+ }
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
+ continue;
434
+ }
435
+ if (notBefore && notBefore.getTime() - skew > now) {
436
+ reasons.push('NotBefore has not arrived');
437
+ continue;
438
+ }
439
+ if (!best || notOnOrAfter.getTime() < best.getTime())
440
+ best = notOnOrAfter;
441
+ }
442
+ // `latest` is set whenever `best` is: every qualifying confirmation was
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` : ''}`;
519
+ }
@@ -0,0 +1,15 @@
1
+ import type { Document, Element } from '@xmldom/xmldom';
2
+ /**
3
+ * The `ID` rules, which run before any signature reference is resolved.
4
+ *
5
+ * XML-DSig resolves its reference by `ID`. Two elements sharing one make
6
+ * "which element is signed" a question the parser answers rather than the
7
+ * specification, and that ambiguity is the classic lever for signature
8
+ * wrapping. So uniqueness is established first, across the whole document —
9
+ * not only across the two elements this validator happens to read.
10
+ */
11
+ /** The first ID value appearing more than once, or null when all are unique. */
12
+ export declare function findDuplicateId(doc: Document): string | null;
13
+ /** The element's ID, or null when it is absent or empty. */
14
+ export declare function readRequiredId(element: Element): string | null;
15
+ //# sourceMappingURL=documentIds.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"documentIds.d.ts","sourceRoot":"","sources":["../../src/validation/documentIds.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAC;AAExD;;;;;;;;GAQG;AAEH,gFAAgF;AAChF,wBAAgB,eAAe,CAAC,GAAG,EAAE,QAAQ,GAAG,MAAM,GAAG,IAAI,CAU5D;AAED,4DAA4D;AAC5D,wBAAgB,cAAc,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAG9D"}
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.findDuplicateId = findDuplicateId;
4
+ exports.readRequiredId = readRequiredId;
5
+ /**
6
+ * The `ID` rules, which run before any signature reference is resolved.
7
+ *
8
+ * XML-DSig resolves its reference by `ID`. Two elements sharing one make
9
+ * "which element is signed" a question the parser answers rather than the
10
+ * specification, and that ambiguity is the classic lever for signature
11
+ * wrapping. So uniqueness is established first, across the whole document —
12
+ * not only across the two elements this validator happens to read.
13
+ */
14
+ /** The first ID value appearing more than once, or null when all are unique. */
15
+ function findDuplicateId(doc) {
16
+ const seen = new Set();
17
+ const elements = doc.getElementsByTagName('*');
18
+ for (let i = 0; i < elements.length; i++) {
19
+ const id = elements[i].getAttribute('ID');
20
+ if (!id)
21
+ continue;
22
+ if (seen.has(id))
23
+ return id;
24
+ seen.add(id);
25
+ }
26
+ return null;
27
+ }
28
+ /** The element's ID, or null when it is absent or empty. */
29
+ function readRequiredId(element) {
30
+ const id = element.getAttribute('ID');
31
+ return id ? id : null;
32
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Remembering assertions so a replay is refused.
3
+ *
4
+ * In memory, and therefore per process. That is honest rather than sufficient:
5
+ * a deployment running several processes needs a shared store, which is why
6
+ * the interface exists at all. What this must not be is per provider instance —
7
+ * a store an attacker escapes by causing a second provider to be constructed
8
+ * is no store.
9
+ *
10
+ * Pruning is lazy, on access, so nothing here holds a timer and nothing needs
11
+ * disposing.
12
+ */
13
+ import type { IAssertionReplayStore } from '@mcp-abap-adt/interfaces-auth';
14
+ /** A store of its own, for a test or a consumer wanting isolation. */
15
+ export declare function createInMemoryReplayStore(): IAssertionReplayStore;
16
+ /**
17
+ * The store the shipped validator uses when the consumer supplies none.
18
+ *
19
+ * Module-level, so every default validator in the process shares it.
20
+ */
21
+ export declare const defaultReplayStore: IAssertionReplayStore;
22
+ //# sourceMappingURL=inMemoryReplayStore.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"inMemoryReplayStore.d.ts","sourceRoot":"","sources":["../../src/validation/inMemoryReplayStore.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAEV,qBAAqB,EACtB,MAAM,+BAA+B,CAAC;AAOvC,sEAAsE;AACtE,wBAAgB,yBAAyB,IAAI,qBAAqB,CAuBjE;AAED;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,EAAE,qBACJ,CAAC"}
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+ /**
3
+ * Remembering assertions so a replay is refused.
4
+ *
5
+ * In memory, and therefore per process. That is honest rather than sufficient:
6
+ * a deployment running several processes needs a shared store, which is why
7
+ * the interface exists at all. What this must not be is per provider instance —
8
+ * a store an attacker escapes by causing a second provider to be constructed
9
+ * is no store.
10
+ *
11
+ * Pruning is lazy, on access, so nothing here holds a timer and nothing needs
12
+ * disposing.
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.defaultReplayStore = void 0;
16
+ exports.createInMemoryReplayStore = createInMemoryReplayStore;
17
+ const compositeKey = (key) =>
18
+ // The issuer is length-prefixed so that two different pairs cannot collide
19
+ // by putting the separator inside an identifier.
20
+ `${key.issuer.length}:${key.issuer}:${key.assertionId}`;
21
+ /** A store of its own, for a test or a consumer wanting isolation. */
22
+ function createInMemoryReplayStore() {
23
+ const seen = new Map();
24
+ return {
25
+ async recordIfUnseen(key, retainUntil) {
26
+ const now = Date.now();
27
+ // Lazy prune: drop everything whose retention has passed, so the map
28
+ // cannot grow without bound and no timer is needed.
29
+ for (const [existing, until] of seen) {
30
+ if (until <= now)
31
+ seen.delete(existing);
32
+ }
33
+ const composite = compositeKey(key);
34
+ if (seen.has(composite))
35
+ return false;
36
+ // Nothing awaits between the check and the write, so this is atomic
37
+ // against other callers on the same event loop. A shared-store
38
+ // implementation must achieve the same with a conditional write.
39
+ seen.set(composite, retainUntil.getTime());
40
+ return true;
41
+ },
42
+ };
43
+ }
44
+ /**
45
+ * The store the shipped validator uses when the consumer supplies none.
46
+ *
47
+ * Module-level, so every default validator in the process shares it.
48
+ */
49
+ exports.defaultReplayStore = createInMemoryReplayStore();