@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.
- package/CHANGELOG.md +193 -0
- package/README.md +825 -64
- package/dist/__tests__/integration/stand/formLogin.d.ts +68 -0
- package/dist/__tests__/integration/stand/formLogin.d.ts.map +1 -0
- package/dist/__tests__/integration/stand/formLogin.js +194 -0
- package/dist/auth/callbackServer.js +2 -2
- package/dist/auth/passcodeAuth.d.ts +25 -0
- package/dist/auth/passcodeAuth.d.ts.map +1 -0
- package/dist/auth/passcodeAuth.js +62 -0
- package/dist/auth/saml2Auth.d.ts +6 -2
- package/dist/auth/saml2Auth.d.ts.map +1 -1
- package/dist/auth/saml2Auth.js +9 -20
- package/dist/auth/samlBearerAssertion.d.ts +24 -0
- package/dist/auth/samlBearerAssertion.d.ts.map +1 -0
- package/dist/auth/samlBearerAssertion.js +102 -0
- package/dist/auth/strictXml.d.ts +13 -0
- package/dist/auth/strictXml.d.ts.map +1 -0
- package/dist/auth/strictXml.js +21 -0
- package/dist/errors/AssertionValidationError.d.ts +15 -0
- package/dist/errors/AssertionValidationError.d.ts.map +1 -0
- package/dist/errors/AssertionValidationError.js +24 -0
- package/dist/errors/TokenProviderErrors.d.ts +2 -0
- package/dist/errors/TokenProviderErrors.d.ts.map +1 -1
- package/dist/errors/TokenProviderErrors.js +3 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -2
- package/dist/providers/Saml2BearerProvider.d.ts +1 -0
- package/dist/providers/Saml2BearerProvider.d.ts.map +1 -1
- package/dist/providers/Saml2BearerProvider.js +20 -2
- package/dist/providers/Saml2PureProvider.d.ts +1 -0
- package/dist/providers/Saml2PureProvider.d.ts.map +1 -1
- package/dist/providers/Saml2PureProvider.js +19 -5
- package/dist/providers/UaaPasscodeProvider.d.ts +43 -0
- package/dist/providers/UaaPasscodeProvider.d.ts.map +1 -0
- package/dist/providers/UaaPasscodeProvider.js +86 -0
- package/dist/providers/index.d.ts +2 -2
- package/dist/providers/index.d.ts.map +1 -1
- package/dist/providers/index.js +3 -3
- package/dist/providers/saml2Utils.d.ts +49 -2
- package/dist/providers/saml2Utils.d.ts.map +1 -1
- package/dist/providers/saml2Utils.js +94 -2
- package/dist/strategies/index.d.ts +1 -1
- package/dist/strategies/index.d.ts.map +1 -1
- package/dist/strategies/index.js +2 -1
- package/dist/strategies/manualStrategies.d.ts +7 -0
- package/dist/strategies/manualStrategies.d.ts.map +1 -1
- package/dist/strategies/manualStrategies.js +21 -0
- package/dist/validation/assertionValidator.d.ts +28 -0
- package/dist/validation/assertionValidator.d.ts.map +1 -0
- package/dist/validation/assertionValidator.js +444 -0
- package/dist/validation/documentIds.d.ts +15 -0
- package/dist/validation/documentIds.d.ts.map +1 -0
- package/dist/validation/documentIds.js +32 -0
- package/dist/validation/inMemoryReplayStore.d.ts +22 -0
- package/dist/validation/inMemoryReplayStore.d.ts.map +1 -0
- package/dist/validation/inMemoryReplayStore.js +49 -0
- package/dist/validation/signedNode.d.ts +54 -0
- package/dist/validation/signedNode.d.ts.map +1 -0
- package/dist/validation/signedNode.js +171 -0
- package/dist/validation/xsdDateTime.d.ts +17 -0
- package/dist/validation/xsdDateTime.d.ts.map +1 -0
- package/dist/validation/xsdDateTime.js +67 -0
- package/package.json +14 -8
- package/bin/auth-device-flow.ts +0 -114
- package/dist/auth/deviceFlowAuth.d.ts +0 -43
- package/dist/auth/deviceFlowAuth.d.ts.map +0 -1
- package/dist/auth/deviceFlowAuth.js +0 -168
- package/dist/providers/DeviceFlowProvider.d.ts +0 -32
- package/dist/providers/DeviceFlowProvider.d.ts.map +0 -1
- 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
|
+
* ` ` 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"}
|