@mcp-abap-adt/auth-providers 2.2.2 → 4.0.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 (71) hide show
  1. package/CHANGELOG.md +193 -0
  2. package/README.md +825 -64
  3. package/dist/__tests__/integration/stand/formLogin.d.ts +68 -0
  4. package/dist/__tests__/integration/stand/formLogin.d.ts.map +1 -0
  5. package/dist/__tests__/integration/stand/formLogin.js +194 -0
  6. package/dist/auth/callbackServer.js +2 -2
  7. package/dist/auth/passcodeAuth.d.ts +25 -0
  8. package/dist/auth/passcodeAuth.d.ts.map +1 -0
  9. package/dist/auth/passcodeAuth.js +62 -0
  10. package/dist/auth/saml2Auth.d.ts +6 -2
  11. package/dist/auth/saml2Auth.d.ts.map +1 -1
  12. package/dist/auth/saml2Auth.js +9 -20
  13. package/dist/auth/samlBearerAssertion.d.ts +24 -0
  14. package/dist/auth/samlBearerAssertion.d.ts.map +1 -0
  15. package/dist/auth/samlBearerAssertion.js +102 -0
  16. package/dist/auth/strictXml.d.ts +13 -0
  17. package/dist/auth/strictXml.d.ts.map +1 -0
  18. package/dist/auth/strictXml.js +21 -0
  19. package/dist/errors/AssertionValidationError.d.ts +15 -0
  20. package/dist/errors/AssertionValidationError.d.ts.map +1 -0
  21. package/dist/errors/AssertionValidationError.js +24 -0
  22. package/dist/errors/TokenProviderErrors.d.ts +2 -0
  23. package/dist/errors/TokenProviderErrors.d.ts.map +1 -1
  24. package/dist/errors/TokenProviderErrors.js +3 -1
  25. package/dist/index.d.ts +6 -3
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +13 -2
  28. package/dist/providers/Saml2BearerProvider.d.ts +1 -0
  29. package/dist/providers/Saml2BearerProvider.d.ts.map +1 -1
  30. package/dist/providers/Saml2BearerProvider.js +20 -2
  31. package/dist/providers/Saml2PureProvider.d.ts +1 -0
  32. package/dist/providers/Saml2PureProvider.d.ts.map +1 -1
  33. package/dist/providers/Saml2PureProvider.js +19 -5
  34. package/dist/providers/UaaPasscodeProvider.d.ts +43 -0
  35. package/dist/providers/UaaPasscodeProvider.d.ts.map +1 -0
  36. package/dist/providers/UaaPasscodeProvider.js +86 -0
  37. package/dist/providers/index.d.ts +2 -2
  38. package/dist/providers/index.d.ts.map +1 -1
  39. package/dist/providers/index.js +3 -3
  40. package/dist/providers/saml2Utils.d.ts +49 -2
  41. package/dist/providers/saml2Utils.d.ts.map +1 -1
  42. package/dist/providers/saml2Utils.js +94 -2
  43. package/dist/strategies/index.d.ts +1 -1
  44. package/dist/strategies/index.d.ts.map +1 -1
  45. package/dist/strategies/index.js +2 -1
  46. package/dist/strategies/manualStrategies.d.ts +7 -0
  47. package/dist/strategies/manualStrategies.d.ts.map +1 -1
  48. package/dist/strategies/manualStrategies.js +21 -0
  49. package/dist/validation/assertionValidator.d.ts +28 -0
  50. package/dist/validation/assertionValidator.d.ts.map +1 -0
  51. package/dist/validation/assertionValidator.js +444 -0
  52. package/dist/validation/documentIds.d.ts +15 -0
  53. package/dist/validation/documentIds.d.ts.map +1 -0
  54. package/dist/validation/documentIds.js +32 -0
  55. package/dist/validation/inMemoryReplayStore.d.ts +22 -0
  56. package/dist/validation/inMemoryReplayStore.d.ts.map +1 -0
  57. package/dist/validation/inMemoryReplayStore.js +49 -0
  58. package/dist/validation/signedNode.d.ts +54 -0
  59. package/dist/validation/signedNode.d.ts.map +1 -0
  60. package/dist/validation/signedNode.js +171 -0
  61. package/dist/validation/xsdDateTime.d.ts +17 -0
  62. package/dist/validation/xsdDateTime.d.ts.map +1 -0
  63. package/dist/validation/xsdDateTime.js +67 -0
  64. package/package.json +14 -8
  65. package/bin/auth-device-flow.ts +0 -114
  66. package/dist/auth/deviceFlowAuth.d.ts +0 -43
  67. package/dist/auth/deviceFlowAuth.d.ts.map +0 -1
  68. package/dist/auth/deviceFlowAuth.js +0 -168
  69. package/dist/providers/DeviceFlowProvider.d.ts +0 -32
  70. package/dist/providers/DeviceFlowProvider.d.ts.map +0 -1
  71. package/dist/providers/DeviceFlowProvider.js +0 -86
@@ -0,0 +1,444 @@
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
+ /**
34
+ * Marks a validator as one of the two shipped here. Module-private and
35
+ * non-enumerable, so it is neither part of the public surface nor visible to
36
+ * a consumer spreading or serialising the object.
37
+ *
38
+ * It exists for one reason: a shipped validator fails closed without
39
+ * `expectedIssuer`, so a provider handed one must insist on `idpEntityId` at
40
+ * construction — otherwise the mistake surfaces only as an `issuer` refusal
41
+ * after a human has finished a browser login. A custom validator carries no
42
+ * brand and may establish trust however it likes.
43
+ */
44
+ const SHIPPED = Symbol('mcp-abap-adt.shippedAssertionValidator');
45
+ function brand(validator) {
46
+ Object.defineProperty(validator, SHIPPED, {
47
+ value: true,
48
+ enumerable: false,
49
+ });
50
+ return validator;
51
+ }
52
+ /** Whether this validator came from one of the two shipped factories. */
53
+ function isShippedValidator(validator) {
54
+ return validator[SHIPPED] === true;
55
+ }
56
+ const createSignedResponseValidator = (options) => brand(createValidator('response', options));
57
+ exports.createSignedResponseValidator = createSignedResponseValidator;
58
+ const createSignedAssertionValidator = (options) => brand(createValidator('assertion', options));
59
+ exports.createSignedAssertionValidator = createSignedAssertionValidator;
60
+ function createValidator(require, options) {
61
+ const skew = options.clockSkewMs ?? 0;
62
+ if (!Number.isInteger(skew) || skew < 0) {
63
+ throw new Error(`clockSkewMs must be a finite non-negative integer, got ${String(options.clockSkewMs)}`);
64
+ }
65
+ if (options.idpCertificates.length === 0) {
66
+ throw new Error('idpCertificates must not be empty: nothing could be verified');
67
+ }
68
+ // Normalised and proved here, once, rather than inside verification. Three
69
+ // reasons, and the last bites hardest: a malformed entry standing first in
70
+ // the list would abort the rotation loop before a later valid certificate
71
+ // was tried; a constructor is where this package already refuses a bad
72
+ // configuration; and a login happens after a human has used a browser, so a
73
+ // formatting mistake found then wastes their work, not ours.
74
+ const certificates = options.idpCertificates.map(signedNode_1.toPem);
75
+ const store = options.replayStore ?? inMemoryReplayStore_1.defaultReplayStore;
76
+ return {
77
+ async validate(samlResponse, context) {
78
+ // 1. Parses, and the document element is a samlp:Response.
79
+ const xml = Buffer.from(samlResponse, 'base64').toString('utf8');
80
+ // No DTD, ever. A SAML message has no use for one, and a DOCTYPE is
81
+ // where parsers diverge — entity expansion, internal subsets — and this
82
+ // document is parsed twice: by @xmldom/xmldom 0.9 here and by the 0.8
83
+ // nested inside xml-crypto. Refused before either parse is trusted.
84
+ if (/<!DOCTYPE/i.test(xml)) {
85
+ return fail('document', 'the SAMLResponse carries a DOCTYPE declaration, which is never accepted');
86
+ }
87
+ let doc;
88
+ try {
89
+ doc = (0, strictXml_1.parseStrictXml)(xml);
90
+ }
91
+ catch {
92
+ return fail('document', 'the SAMLResponse did not parse as XML');
93
+ }
94
+ const root = doc.documentElement;
95
+ if (!root)
96
+ return fail('document', 'the SAMLResponse did not parse as XML');
97
+ const rootIsResponse = root.localName === 'Response' && root.namespaceURI === PROTOCOL_NS;
98
+ const rootIsAssertion = root.localName === 'Assertion' && root.namespaceURI === SAML_NS;
99
+ // A bare Assertion is a document only the assertion-only validator
100
+ // accepts: the saml2-bearer grant exchanges an Assertion, and 3.0.0
101
+ // already takes one as Saml2BearerProvider's payload.
102
+ if (!rootIsResponse && !(require === 'assertion' && rootIsAssertion)) {
103
+ return fail('document', require === 'assertion'
104
+ ? `expected a samlp:Response or a saml:Assertion, got ${(0, signedNode_1.quoteUntrusted)(root.localName ?? '')}`
105
+ : `expected the document element to be a samlp:Response, got ${(0, signedNode_1.quoteUntrusted)(root.localName ?? '')}`);
106
+ }
107
+ // 1b. Unique IDs, before any reference is resolved.
108
+ const duplicate = (0, documentIds_1.findDuplicateId)(doc);
109
+ if (duplicate) {
110
+ return fail('duplicateId', `the document uses the ID ${(0, signedNode_1.quoteUntrusted)(duplicate)} more than once, so which element is signed is ambiguous`);
111
+ }
112
+ // 2 + 3. Verify every signature, then take the element this validator
113
+ // requires from among those they cover. A response signed at both
114
+ // levels — as many identity providers send — satisfies either validator.
115
+ let covered;
116
+ try {
117
+ covered = (0, signedNode_1.resolveSignedElements)(xml, doc, certificates);
118
+ }
119
+ catch (error) {
120
+ return fail('signature', error.message);
121
+ }
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');
144
+ }
145
+ // 3b. Nothing assertion-shaped outside the one read. Wherever the
146
+ // signature sits, the payload travels on whole — Saml2PureProvider hands
147
+ // it to the cookie provider — so an Assertion or EncryptedAssertion in
148
+ // 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');
152
+ }
153
+ // 4. Status. Only when the Response is the signed element: otherwise it
154
+ // lies outside the signature, and checking a field an attacker sets is
155
+ // worse than not checking it — it reads like verification.
156
+ 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');
163
+ if (codeValue !== SUCCESS) {
164
+ return fail('status', `the identity provider declined the login: ${codeValue}`);
165
+ }
166
+ }
167
+ // 4b. The assertion's own ID.
168
+ const assertionId = (0, documentIds_1.readRequiredId)(assertion);
169
+ if (!assertionId)
170
+ return fail('assertionId', 'the assertion carries no ID');
171
+ // 5. The assertion's Issuer — inside the signature either way, so both
172
+ // validators check it.
173
+ const issuer = directChild(assertion, SAML_NS, 'Issuer')?.textContent ?? '';
174
+ if (!issuer) {
175
+ return fail('issuer', 'the assertion must carry exactly one non-empty saml:Issuer');
176
+ }
177
+ // Fail closed: with nothing to compare against, any issuer whose key is
178
+ // configured would pass, which is not what this validator promises.
179
+ if (!context.expectedIssuer) {
180
+ return fail('issuer', 'no expectedIssuer was configured, so the assertion issuer cannot be trusted');
181
+ }
182
+ if (issuer !== context.expectedIssuer) {
183
+ return fail('issuer', `the assertion was issued by ${issuer}, not the trusted issuer`);
184
+ }
185
+ // 5b. The cross-check against the Response's Issuer belongs to the
186
+ // signed-Response validator alone: only there are both inside the
187
+ // signature.
188
+ if (require === 'response') {
189
+ // Optional, so none is fine; but two are an ambiguity, and one that is
190
+ // present must agree — empty included, since empty is not absent.
191
+ const responseIssuers = directChildren(root, SAML_NS, 'Issuer');
192
+ if (responseIssuers.length > 1) {
193
+ return fail('issuer', 'the response must carry at most one saml:Issuer');
194
+ }
195
+ if (responseIssuers.length === 1 &&
196
+ (responseIssuers[0].textContent ?? '') !== issuer) {
197
+ return fail('issuer', 'the response and the assertion name different issuers');
198
+ }
199
+ }
200
+ // 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');
204
+ const notBeforeRaw = conditions.getAttribute('NotBefore');
205
+ if (notBeforeRaw) {
206
+ const notBefore = (0, xsdDateTime_1.parseXsdDateTime)(notBeforeRaw);
207
+ if (!notBefore) {
208
+ return fail('notBefore', `Conditions NotBefore is not a valid xsd:dateTime: ${notBeforeRaw}`);
209
+ }
210
+ if (notBefore.getTime() - skew > Date.now()) {
211
+ return fail('notBefore', 'the assertion is not valid yet');
212
+ }
213
+ }
214
+ const conditionsExpiry = (0, xsdDateTime_1.parseXsdDateTime)(conditions.getAttribute('NotOnOrAfter'));
215
+ if (!conditionsExpiry) {
216
+ return fail('notOnOrAfter', 'Conditions carries no usable NotOnOrAfter, so the assertion states no lifetime');
217
+ }
218
+ if (conditionsExpiry.getTime() + skew <= Date.now()) {
219
+ return fail('notOnOrAfter', 'the assertion has expired');
220
+ }
221
+ // 9. Every AudienceRestriction must name us; several Audience inside one
222
+ // are alternatives.
223
+ const restrictions = directChildren(conditions, SAML_NS, 'AudienceRestriction');
224
+ if (restrictions.length === 0) {
225
+ return fail('audience', 'the assertion restricts no audience');
226
+ }
227
+ for (const restriction of restrictions) {
228
+ const names = directChildren(restriction, SAML_NS, 'Audience').map((a) => a.textContent ?? '');
229
+ if (!names.includes(context.audience)) {
230
+ return fail('audience', 'an AudienceRestriction on this assertion does not name us');
231
+ }
232
+ }
233
+ // 10. One bearer confirmation satisfying everything together.
234
+ 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
+ // 11. Destination — the signed-Response validator only, for the same
239
+ // reason as Status. Addressing in the other flow rests on Recipient,
240
+ // which step 10 required and which sits inside the signed assertion.
241
+ if (require === 'response') {
242
+ const destination = root.getAttribute('Destination');
243
+ if (!destination) {
244
+ return fail('destination', 'the response carries no Destination');
245
+ }
246
+ if (destination !== context.acsUrl) {
247
+ return fail('destination', `the response is addressed to ${destination}, not to us`);
248
+ }
249
+ }
250
+ // Expiry: the earlier of the two windows.
251
+ const expiresAt = new Date(Math.min(conditionsExpiry.getTime(), chosen.notOnOrAfter.getTime()));
252
+ // 12. Replay. Retention is NOT expiresAt: it must last for as long as
253
+ // this validator could accept the assertion again. Conditions bound
254
+ // that, but so does the LATEST bearer confirmation that can qualify —
255
+ // with confirmations closing at +120 s and +600 s the session ends at
256
+ // +120 s, yet at +200 s the second one still qualifies, and an entry
257
+ // dropped at +120 s would let the same assertion in a second time. The
258
+ // skew is added on top, since inside it the assertion is still accepted.
259
+ const retainUntil = new Date(Math.min(conditionsExpiry.getTime(), chosen.latestNotOnOrAfter.getTime()) + skew);
260
+ const fresh = await store.recordIfUnseen({ issuer, assertionId }, retainUntil);
261
+ if (!fresh) {
262
+ return fail('replay', 'this assertion has been presented before');
263
+ }
264
+ return {
265
+ expiresAt,
266
+ assertionId,
267
+ issuer,
268
+ nameId: (() => {
269
+ // Subject, then NameID — no `?? assertion` fallback, which would
270
+ // read a NameID from outside the Subject when the Subject is absent.
271
+ const subject = directChild(assertion, SAML_NS, 'Subject');
272
+ return subject
273
+ ? (directChild(subject, SAML_NS, 'NameID')?.textContent ??
274
+ undefined)
275
+ : undefined;
276
+ })(),
277
+ raw: samlResponse,
278
+ // The signed element, not the response: this is what a consumer may
279
+ // parse without re-deriving what the signature covered.
280
+ signedXml: new xmldom_1.XMLSerializer().serializeToString(signed),
281
+ };
282
+ },
283
+ };
284
+ }
285
+ function fail(check, message) {
286
+ throw new AssertionValidationError_1.AssertionValidationError(check, message);
287
+ }
288
+ /**
289
+ * Direct children with this namespace and local name — **not** descendants.
290
+ *
291
+ * `getElementsByTagNameNS` searches the whole subtree, and that is the wrong
292
+ * tool for a structural path. An assertion with no `Conditions` of its own but
293
+ * a `Conditions` buried somewhere inside it would answer the descendant search
294
+ * and satisfy a check it does not meet; the same trick works for `Issuer`,
295
+ * `Status` and `Subject`. Each segment of a SAML path is therefore walked
296
+ * explicitly, one level at a time.
297
+ */
298
+ function directChildren(parent, ns, local) {
299
+ const out = [];
300
+ const nodes = parent.childNodes;
301
+ for (let i = 0; i < nodes.length; i++) {
302
+ const node = nodes[i];
303
+ // nodeType 1 is ELEMENT_NODE; the constant is unavailable without the dom
304
+ // lib, which this project deliberately does not use.
305
+ if (node.nodeType === 1 &&
306
+ node.namespaceURI === ns &&
307
+ node.localName === local) {
308
+ out.push(node);
309
+ }
310
+ }
311
+ return out;
312
+ }
313
+ /** The single direct child with this name, or null when there is not exactly one. */
314
+ function directChild(parent, ns, local) {
315
+ const found = directChildren(parent, ns, local);
316
+ // Not "the first": two siblings sharing a name is an ambiguity, and
317
+ // resolving it silently in favour of the first is how a forged element comes
318
+ // to be read in preference to a real one.
319
+ return found.length === 1 ? found[0] : null;
320
+ }
321
+ /**
322
+ * Whether every `saml:Assertion` and `saml:EncryptedAssertion` in the
323
+ * document is `assertion` itself or lies inside it.
324
+ */
325
+ function everyAssertionWithin(doc, assertion) {
326
+ for (const local of ['Assertion', 'EncryptedAssertion']) {
327
+ const found = doc.getElementsByTagNameNS(SAML_NS, local);
328
+ for (let i = 0; i < found.length; i++) {
329
+ let node = found[i];
330
+ while (node && node !== assertion) {
331
+ node = node.parentNode;
332
+ }
333
+ if (!node)
334
+ return false;
335
+ }
336
+ }
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;
380
+ }
381
+ /**
382
+ * The bearer confirmation this login may rely on.
383
+ *
384
+ * Every part must hold on the **same** element: gathering `InResponseTo` from
385
+ * 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.
389
+ *
390
+ * `latestNotOnOrAfter` answers a different question: until when could some
391
+ * confirmation let this assertion in? It is the latest `NotOnOrAfter` among
392
+ * the confirmations that satisfy every non-temporal part — including one whose
393
+ * `NotBefore` has not arrived yet, since it qualifies once it does. Replay
394
+ * retention needs that bound, not the session's; see the caller.
395
+ */
396
+ function chooseBearerConfirmation(assertion, context, skew) {
397
+ const now = Date.now();
398
+ let best = null;
399
+ 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)
408
+ 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
+ }
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;
427
+ // Could qualify at some instant: counts towards how long to remember.
428
+ if (!latest || notOnOrAfter.getTime() > latest.getTime()) {
429
+ latest = notOnOrAfter;
430
+ }
431
+ // Qualifies now: a candidate for the session's window.
432
+ if (notOnOrAfter.getTime() + skew <= now)
433
+ continue;
434
+ if (notBefore && notBefore.getTime() - skew > now)
435
+ continue;
436
+ if (!best || notOnOrAfter.getTime() < best.getTime())
437
+ best = notOnOrAfter;
438
+ }
439
+ // `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;
444
+ }
@@ -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();
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Which element the signature covers.
3
+ *
4
+ * Not "is there a valid signature" — that question has a true answer in a
5
+ * document built for a wrapping attack, where a genuinely signed fragment sits
6
+ * beside a forged one. The caller must read the element this returns and no
7
+ * other.
8
+ */
9
+ import type { Document, Element } from '@xmldom/xmldom';
10
+ /**
11
+ * PEM in, PEM out; bare base64 DER gets its armour — and the result is proved
12
+ * to be a certificate before anything uses it.
13
+ *
14
+ * The spec promises `idpCertificates` accepts either, and a consumer copying
15
+ * `<X509Certificate>` out of identity-provider metadata has bare base64 DER in
16
+ * their hand — the armour is not in the metadata. `xml-crypto` accepts only
17
+ * PEM or a Buffer: measured, a bare base64 certificate makes OpenSSL throw
18
+ * `DECODER routines::unsupported`, while the same bytes re-armoured verify.
19
+ *
20
+ * Left unnormalised that throw would be swallowed by the verification loop and
21
+ * reported as "the signature does not verify against any configured
22
+ * certificate" — blaming the assertion for the consumer's formatting.
23
+ *
24
+ * Base64 syntax alone does not make a string a certificate: `"AAAA"` passes
25
+ * the character class, armours cleanly, and fails only inside OpenSSL, which
26
+ * lands back at the same misleading message. So the armoured result is parsed
27
+ * with `node:crypto`'s `X509Certificate`, which rejects `"AAAA"` with
28
+ * `asn1 encoding routines::wrong tag` — measured, not assumed. Called once per
29
+ * certificate at construction, so the cost never falls on a login.
30
+ */
31
+ export declare function toPem(certificate: string): string;
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
35
+ * `&#10;` shows as `\n` rather than forging a line in a log, and cut to 64
36
+ * characters, so an attacker cannot fill a log with it.
37
+ */
38
+ export declare function quoteUntrusted(value: string): string;
39
+ /**
40
+ * Verifies every signature in the document against the certificates and
41
+ * returns the elements they reference. Throws when there is no signature, or
42
+ * when any one of them fails a rule below.
43
+ *
44
+ * `doc` must be the parse of `xml`, and nothing else: signatures are found and
45
+ * their references resolved in `doc`, while `xml-crypto` verifies the digests
46
+ * over `xml`. Handed a `doc` from different bytes, the element returned would
47
+ * not be the one whose bytes were verified.
48
+ *
49
+ * Several signatures are normal — identity providers often sign the Response
50
+ * and the Assertion both. Each is held to every rule on its own; the caller
51
+ * then takes the element it requires from the returned list.
52
+ */
53
+ export declare function resolveSignedElements(xml: string, doc: Document, certificates: readonly string[]): Element[];
54
+ //# sourceMappingURL=signedNode.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"signedNode.d.ts","sourceRoot":"","sources":["../../src/validation/signedNode.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,KAAK,EAAE,QAAQ,EAAE,OAAO,EAAmB,MAAM,gBAAgB,CAAC;AAKzE;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,KAAK,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CAuBjD;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAKpD;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,qBAAqB,CACnC,GAAG,EAAE,MAAM,EACX,GAAG,EAAE,QAAQ,EACb,YAAY,EAAE,SAAS,MAAM,EAAE,GAC9B,OAAO,EAAE,CAeX"}