@blamejs/core 0.8.60 → 0.8.64

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.
@@ -0,0 +1,636 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.auth.saml
4
+ * @nav Identity
5
+ * @title SAML 2.0 SP
6
+ * @order 370
7
+ * @card SAML 2.0 Service Provider primitive — builds AuthnRequests,
8
+ * parses + verifies IdP-signed Responses, validates the
9
+ * assertion's SubjectConfirmation / Conditions, and
10
+ * defends against XML signature-wrapping via
11
+ * `b.xmlC14n.canonicalizeElementById`'s single-match
12
+ * invariant.
13
+ *
14
+ * @intro
15
+ * SAML 2.0 (OASIS) is the federation protocol financial /
16
+ * government / enterprise IdPs still ship — operators can't always
17
+ * require an OIDC IdP. This primitive implements the SP side
18
+ * only:
19
+ *
20
+ * - AuthnRequest builder (HTTP-Redirect + HTTP-POST bindings)
21
+ * - Response parser:
22
+ * * Verify Response or Assertion XMLDSig (whichever the IdP
23
+ * signed) using the IdP's signing certificate
24
+ * * Refuse signature-wrapping by enforcing single-element-
25
+ * match on the Reference URI (via xml-c14n)
26
+ * * Validate `NotOnOrAfter` / `NotBefore` / `Recipient` /
27
+ * `InResponseTo` on SubjectConfirmation
28
+ * * Validate `Conditions/NotBefore`/`NotOnOrAfter`/
29
+ * `AudienceRestriction`
30
+ * - SP metadata XML emitter
31
+ * - MDQ (RFC 8414-style metadata-query) fetch with strict
32
+ * server-identity per RFC 9525
33
+ *
34
+ * Operators wire two routes:
35
+ *
36
+ * /saml/login → returns the AuthnRequest URL (Redirect binding)
37
+ * OR an HTML form (POST binding) the user-agent
38
+ * submits to the IdP's SSO endpoint.
39
+ * /saml/acs → AssertionConsumerService — receives the IdP's
40
+ * SAMLResponse, calls verifyResponse, hydrates
41
+ * the user session.
42
+ *
43
+ * Storage of `InResponseTo` / RelayState pre-image / nonce is
44
+ * operator-side via b.cache or b.session — the framework gives the
45
+ * parsing + verification primitive; operators wire freshness +
46
+ * replay defense.
47
+ */
48
+
49
+ var lazyRequire = require("../lazy-require");
50
+ var validateOpts = require("../validate-opts");
51
+ var nodeCrypto = require("node:crypto");
52
+ var { generateToken } = require("../crypto");
53
+ var { AuthError } = require("../framework-error");
54
+
55
+ var xmlC14n = lazyRequire(function () { return require("../xml-c14n"); });
56
+ var httpClient = lazyRequire(function () { return require("../http-client"); });
57
+ var audit = lazyRequire(function () { return require("../audit"); });
58
+ var observability = lazyRequire(function () { return require("../observability"); });
59
+ var emit = validateOpts.makeNamespacedEmitters("auth.saml", { audit: audit, observability: observability });
60
+
61
+ var SUPPORTED_DIGEST = { "http://www.w3.org/2001/04/xmlenc#sha256": "sha256",
62
+ "http://www.w3.org/2001/04/xmlenc#sha384": "sha384",
63
+ "http://www.w3.org/2001/04/xmlenc#sha512": "sha512" };
64
+ var SUPPORTED_SIG = { "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256": { hash: "sha256", padding: "pkcs1" },
65
+ "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384": { hash: "sha384", padding: "pkcs1" },
66
+ "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512": { hash: "sha512", padding: "pkcs1" },
67
+ "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256": { hash: "sha256", ec: true },
68
+ "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384": { hash: "sha384", ec: true },
69
+ "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512": { hash: "sha512", ec: true } };
70
+ var SAML_NS = {
71
+ protocol: "urn:oasis:names:tc:SAML:2.0:protocol",
72
+ assertion: "urn:oasis:names:tc:SAML:2.0:assertion",
73
+ metadata: "urn:oasis:names:tc:SAML:2.0:metadata",
74
+ };
75
+
76
+ var _emitAudit = emit.audit;
77
+ var _emitMetric = emit.metric;
78
+
79
+ function _findChild(node, localName, namespace) {
80
+ if (!node || !node.children) return null;
81
+ for (var i = 0; i < node.children.length; i++) {
82
+ var c = node.children[i];
83
+ if (c.type !== "element") continue;
84
+ var colon = c.name.indexOf(":");
85
+ var local = colon !== -1 ? c.name.substring(colon + 1) : c.name;
86
+ if (local !== localName) continue;
87
+ if (namespace) {
88
+ var prefix = colon !== -1 ? c.name.substring(0, colon) : "";
89
+ var ns = _namespaceForPrefix(c, prefix);
90
+ if (ns !== namespace) continue;
91
+ }
92
+ return c;
93
+ }
94
+ return null;
95
+ }
96
+
97
+ function _findAllChildren(node, localName, namespace) {
98
+ var out = [];
99
+ if (!node || !node.children) return out;
100
+ for (var i = 0; i < node.children.length; i++) {
101
+ var c = node.children[i];
102
+ if (c.type !== "element") continue;
103
+ var colon = c.name.indexOf(":");
104
+ var local = colon !== -1 ? c.name.substring(colon + 1) : c.name;
105
+ if (local !== localName) continue;
106
+ if (namespace) {
107
+ var prefix = colon !== -1 ? c.name.substring(0, colon) : "";
108
+ var ns = _namespaceForPrefix(c, prefix);
109
+ if (ns !== namespace) continue;
110
+ }
111
+ out.push(c);
112
+ }
113
+ return out;
114
+ }
115
+
116
+ function _namespaceForPrefix(node, prefix) {
117
+ var cur = node;
118
+ while (cur) {
119
+ if (cur.attrs) {
120
+ for (var i = 0; i < cur.attrs.length; i++) {
121
+ var a = cur.attrs[i];
122
+ if (prefix === "" && a.name === "xmlns") return a.value;
123
+ if (a.name === "xmlns:" + prefix) return a.value;
124
+ }
125
+ }
126
+ cur = cur.parent;
127
+ }
128
+ return null;
129
+ }
130
+
131
+ function _attr(node, name) {
132
+ if (!node || !node.attrs) return null;
133
+ for (var i = 0; i < node.attrs.length; i++) {
134
+ if (node.attrs[i].name === name) return node.attrs[i].value;
135
+ }
136
+ return null;
137
+ }
138
+
139
+ function _textContent(node) {
140
+ if (!node || !node.children) return "";
141
+ var out = "";
142
+ for (var i = 0; i < node.children.length; i++) {
143
+ var c = node.children[i];
144
+ if (c.type === "text") out += c.text;
145
+ else if (c.type === "element") out += _textContent(c);
146
+ }
147
+ return out.trim();
148
+ }
149
+
150
+ function _verifyXmldsig(envelope, signatureNode, certPem) {
151
+ // Parse SignedInfo + extract canonicalization, signature, and
152
+ // reference algorithms. Then:
153
+ // 1. Locate the referenced element by its ID attribute (single-
154
+ // match invariant from xml-c14n.canonicalizeElementById)
155
+ // 2. C14n + hash that element, compare to Reference DigestValue
156
+ // 3. C14n SignedInfo, verify the signature against the cert pubkey
157
+ var signedInfo = _findChild(signatureNode, "SignedInfo");
158
+ if (!signedInfo) {
159
+ throw new AuthError("auth-saml/no-signed-info", "Signature missing SignedInfo");
160
+ }
161
+ var canonMethodNode = _findChild(signedInfo, "CanonicalizationMethod");
162
+ var canonAlgo = canonMethodNode && _attr(canonMethodNode, "Algorithm");
163
+ if (canonAlgo !== "http://www.w3.org/2001/10/xml-exc-c14n#" &&
164
+ canonAlgo !== "http://www.w3.org/2001/10/xml-exc-c14n#WithComments") {
165
+ throw new AuthError("auth-saml/unsupported-c14n",
166
+ "Unsupported CanonicalizationMethod: " + canonAlgo + " (only xml-exc-c14n supported)");
167
+ }
168
+ var sigMethodNode = _findChild(signedInfo, "SignatureMethod");
169
+ var sigAlgo = sigMethodNode && _attr(sigMethodNode, "Algorithm");
170
+ if (!SUPPORTED_SIG[sigAlgo]) {
171
+ throw new AuthError("auth-saml/unsupported-sig-alg",
172
+ "Unsupported SignatureMethod: " + sigAlgo);
173
+ }
174
+ var refNode = _findChild(signedInfo, "Reference");
175
+ if (!refNode) throw new AuthError("auth-saml/no-reference", "SignedInfo missing Reference");
176
+ var refUri = _attr(refNode, "URI") || "";
177
+ if (refUri.charAt(0) !== "#") {
178
+ throw new AuthError("auth-saml/external-reference",
179
+ "Reference URI must be a same-document fragment (got \"" + refUri + "\")");
180
+ }
181
+ var refId = refUri.substring(1);
182
+ var digestMethodNode = _findChild(refNode, "DigestMethod");
183
+ var digestAlgo = digestMethodNode && _attr(digestMethodNode, "Algorithm");
184
+ if (!SUPPORTED_DIGEST[digestAlgo]) {
185
+ throw new AuthError("auth-saml/unsupported-digest",
186
+ "Unsupported DigestMethod: " + digestAlgo);
187
+ }
188
+ var digestValueNode = _findChild(refNode, "DigestValue");
189
+ var expectedDigestB64 = _textContent(digestValueNode);
190
+ if (!expectedDigestB64) {
191
+ throw new AuthError("auth-saml/no-digest-value", "Reference missing DigestValue");
192
+ }
193
+ var withComments = canonAlgo.indexOf("#WithComments") !== -1;
194
+ // Single-match invariant — anti-wrapping defense
195
+ var canonical = xmlC14n().canonicalizeElementById(envelope, refId, {
196
+ withComments: withComments,
197
+ });
198
+ var actualDigest = nodeCrypto.createHash(SUPPORTED_DIGEST[digestAlgo]).update(canonical).digest();
199
+ if (Buffer.from(expectedDigestB64, "base64").compare(actualDigest) !== 0) {
200
+ throw new AuthError("auth-saml/digest-mismatch",
201
+ "Reference DigestValue does not match canonicalized referenced element (signature-wrapping or tampered content)");
202
+ }
203
+
204
+ // C14n SignedInfo as a parsed-tree node — we need to canonicalize
205
+ // the SignedInfo element ITSELF, not look it up by ID. Slice the
206
+ // serialized SignedInfo from the parsed tree.
207
+ var signedInfoCanonical = xmlC14n().canonicalize(signedInfo, { withComments: withComments });
208
+
209
+ // Resolve signer key from cert
210
+ var cert = nodeCrypto.createPublicKey({ key: certPem, format: "pem" });
211
+
212
+ var sigValueNode = _findChild(signatureNode, "SignatureValue");
213
+ var sigB64 = _textContent(sigValueNode).replace(/\s+/g, "");
214
+ if (!sigB64) throw new AuthError("auth-saml/no-signature-value", "Signature missing SignatureValue");
215
+ var sigBytes = Buffer.from(sigB64, "base64");
216
+
217
+ var sigSpec = SUPPORTED_SIG[sigAlgo];
218
+ var verifyOpts = { key: cert };
219
+ if (sigSpec.padding === "pkcs1") verifyOpts.padding = nodeCrypto.constants.RSA_PKCS1_PADDING;
220
+ if (sigSpec.ec) verifyOpts.dsaEncoding = "der";
221
+ var ok = nodeCrypto.verify(sigSpec.hash, signedInfoCanonical, verifyOpts, sigBytes);
222
+ if (!ok) {
223
+ throw new AuthError("auth-saml/bad-signature", "SAML signature verification failed");
224
+ }
225
+ return { refId: refId };
226
+ }
227
+
228
+ /**
229
+ * @primitive b.auth.saml.sp.create
230
+ * @signature b.auth.saml.sp.create(opts)
231
+ * @since 0.8.62
232
+ * @status stable
233
+ * @related b.xmlC14n.canonicalizeElementById, b.network.tls.checkServerIdentity9525
234
+ *
235
+ * Build a SAML 2.0 SP. Operators supply:
236
+ * - the SP entityId (this RP's URL)
237
+ * - assertionConsumerServiceUrl (the /saml/acs route)
238
+ * - idpEntityId + idpSsoUrl + idpCertPem (the trust anchor for
239
+ * this SP — typically rotated quarterly via MDQ)
240
+ *
241
+ * @opts
242
+ * {
243
+ * entityId: string, // this SP's entityID URL
244
+ * assertionConsumerServiceUrl: string, // SP /saml/acs endpoint
245
+ * idpEntityId: string,
246
+ * idpSsoUrl: string, // IdP single-sign-on endpoint
247
+ * idpCertPem: string, // IdP signing cert (PEM)
248
+ * audience?: string, // default = entityId
249
+ * clockSkewSec?: number, // default 60
250
+ * nameIdFormat?: string, // optional NameIDPolicy/Format
251
+ * }
252
+ *
253
+ * @example
254
+ * var sp = b.auth.saml.sp.create({
255
+ * entityId: "https://sp.example",
256
+ * assertionConsumerServiceUrl: "https://sp.example/saml/acs",
257
+ * idpEntityId: "https://idp.example",
258
+ * idpSsoUrl: "https://idp.example/sso",
259
+ * idpCertPem: process.env.IDP_CERT_PEM,
260
+ * });
261
+ */
262
+ function create(opts) {
263
+ validateOpts.requireObject(opts, "auth.saml.sp.create", AuthError);
264
+ validateOpts.requireNonEmptyString(opts.entityId, "entityId", AuthError, "auth-saml/no-entity-id");
265
+ validateOpts.requireNonEmptyString(opts.assertionConsumerServiceUrl, "assertionConsumerServiceUrl",
266
+ AuthError, "auth-saml/no-acs");
267
+ validateOpts.requireNonEmptyString(opts.idpEntityId, "idpEntityId", AuthError, "auth-saml/no-idp-entity-id");
268
+ validateOpts.requireNonEmptyString(opts.idpSsoUrl, "idpSsoUrl", AuthError, "auth-saml/no-idp-sso");
269
+ validateOpts.requireNonEmptyString(opts.idpCertPem, "idpCertPem", AuthError, "auth-saml/no-idp-cert");
270
+
271
+ var audience = opts.audience || opts.entityId;
272
+ var clockSkewSec = typeof opts.clockSkewSec === "number" ? opts.clockSkewSec : 60; // allow:raw-time-literal — clock-skew default
273
+
274
+ /**
275
+ * @primitive b.auth.saml.sp.buildAuthnRequest
276
+ * @signature b.auth.saml.sp.buildAuthnRequest(opts)
277
+ * @since 0.8.62
278
+ *
279
+ * Build a SAMLRequest XML + the URL-safe deflate-base64 encoding
280
+ * for the HTTP-Redirect binding. Returns `{ id, redirectUrl, raw }`
281
+ * where `id` is the AuthnRequest ID the SP must remember (binds to
282
+ * the response's `InResponseTo`).
283
+ *
284
+ * @opts
285
+ * { relayState?: string }
286
+ *
287
+ * @example
288
+ * var ar = sp.buildAuthnRequest({ relayState: "/dashboard" });
289
+ * res.statusCode = 302;
290
+ * res.setHeader("Location", ar.redirectUrl);
291
+ * res.end();
292
+ * // remember ar.id; expect it back in the Response InResponseTo
293
+ */
294
+ function buildAuthnRequest(bopts) {
295
+ bopts = bopts || {};
296
+ var id = "_" + generateToken(20);
297
+ var issueInstant = new Date().toISOString();
298
+ var nameIdPolicy = "";
299
+ if (opts.nameIdFormat) {
300
+ nameIdPolicy = "<samlp:NameIDPolicy Format=\"" + opts.nameIdFormat +
301
+ "\" AllowCreate=\"true\"/>";
302
+ }
303
+ var xml =
304
+ "<samlp:AuthnRequest xmlns:samlp=\"" + SAML_NS.protocol + "\" " +
305
+ "xmlns:saml=\"" + SAML_NS.assertion + "\" " +
306
+ "ID=\"" + id + "\" " +
307
+ "Version=\"2.0\" " +
308
+ "IssueInstant=\"" + issueInstant + "\" " +
309
+ "Destination=\"" + opts.idpSsoUrl + "\" " +
310
+ "AssertionConsumerServiceURL=\"" + opts.assertionConsumerServiceUrl + "\" " +
311
+ "ProtocolBinding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST\">" +
312
+ "<saml:Issuer>" + opts.entityId + "</saml:Issuer>" +
313
+ nameIdPolicy +
314
+ "</samlp:AuthnRequest>";
315
+ var zlib = require("node:zlib");
316
+ var deflated = zlib.deflateRawSync(Buffer.from(xml, "utf8"));
317
+ var samlRequest = encodeURIComponent(deflated.toString("base64"));
318
+ var url = opts.idpSsoUrl + (opts.idpSsoUrl.indexOf("?") === -1 ? "?" : "&") +
319
+ "SAMLRequest=" + samlRequest;
320
+ if (bopts.relayState) {
321
+ url += "&RelayState=" + encodeURIComponent(bopts.relayState);
322
+ }
323
+ _emitAudit("authnrequest_built", "success", { id: id, idp: opts.idpEntityId });
324
+ _emitMetric("authn-request-built");
325
+ return { id: id, redirectUrl: url, raw: xml };
326
+ }
327
+
328
+ /**
329
+ * @primitive b.auth.saml.sp.verifyResponse
330
+ * @signature b.auth.saml.sp.verifyResponse(samlResponseB64, vopts)
331
+ * @since 0.8.62
332
+ *
333
+ * Parse + verify the IdP's SAMLResponse (the base64-encoded XML
334
+ * the user-agent POSTs to /saml/acs). Validates the XMLDSig
335
+ * (Response-level OR Assertion-level signature), the assertion's
336
+ * SubjectConfirmation Bearer constraints, and Conditions audience
337
+ * + time bounds. Returns `{ nameId, nameIdFormat, sessionIndex,
338
+ * attributes, audience, inResponseTo }`.
339
+ *
340
+ * @opts
341
+ * {
342
+ * expectedInResponseTo?: string, // the AuthnRequest ID this is responding to
343
+ * now?: number, // timestamp override for tests
344
+ * }
345
+ *
346
+ * @example
347
+ * app.post("/saml/acs", function (req, res) {
348
+ * var info = sp.verifyResponse(req.body.SAMLResponse, {
349
+ * expectedInResponseTo: req.session.samlRequestId,
350
+ * });
351
+ * // → { nameId, nameIdFormat, sessionIndex, attributes, audience, issuer }
352
+ * });
353
+ */
354
+ function verifyResponse(samlResponseB64, vopts) {
355
+ vopts = vopts || {};
356
+ if (typeof samlResponseB64 !== "string" || samlResponseB64.length === 0) {
357
+ throw new AuthError("auth-saml/no-response", "verifyResponse: SAMLResponse required");
358
+ }
359
+ var xml = Buffer.from(samlResponseB64, "base64").toString("utf8");
360
+ if (!xml || xml.indexOf("<") === -1) {
361
+ throw new AuthError("auth-saml/bad-response-decode",
362
+ "verifyResponse: SAMLResponse base64 decode produced no XML");
363
+ }
364
+ var c14n = xmlC14n();
365
+ var root = c14n.parse(xml);
366
+ // Root must be Response
367
+ var rootColon = root.name.indexOf(":");
368
+ var rootLocal = rootColon !== -1 ? root.name.substring(rootColon + 1) : root.name;
369
+ if (rootLocal !== "Response") {
370
+ throw new AuthError("auth-saml/wrong-root",
371
+ "verifyResponse: root element must be Response, got " + rootLocal);
372
+ }
373
+
374
+ // Validate Status
375
+ var status = _findChild(root, "Status", SAML_NS.protocol);
376
+ var statusCode = status && _findChild(status, "StatusCode", SAML_NS.protocol);
377
+ var statusValue = statusCode && _attr(statusCode, "Value");
378
+ if (statusValue !== "urn:oasis:names:tc:SAML:2.0:status:Success") {
379
+ throw new AuthError("auth-saml/bad-status",
380
+ "verifyResponse: SAML Status is not Success: " + statusValue);
381
+ }
382
+
383
+ // Validate signature: prefer Assertion-level (most secure — the
384
+ // assertion is the security-critical element). Fall back to
385
+ // Response-level when the IdP signs the envelope only.
386
+ var assertion = _findChild(root, "Assertion", SAML_NS.assertion);
387
+ if (!assertion) {
388
+ throw new AuthError("auth-saml/no-assertion", "verifyResponse: Response has no Assertion");
389
+ }
390
+
391
+ var assertionSignature = _findChild(assertion, "Signature");
392
+ var responseSignature = _findChild(root, "Signature");
393
+
394
+ if (!assertionSignature && !responseSignature) {
395
+ throw new AuthError("auth-saml/unsigned",
396
+ "verifyResponse: neither Response nor Assertion is signed — SAML SP refuses unsigned responses");
397
+ }
398
+ var signed;
399
+ if (assertionSignature) {
400
+ signed = _verifyXmldsig(xml, assertionSignature, opts.idpCertPem);
401
+ if (signed.refId !== _attr(assertion, "ID")) {
402
+ throw new AuthError("auth-saml/signed-different-element",
403
+ "verifyResponse: assertion signature references a different element ID");
404
+ }
405
+ } else {
406
+ signed = _verifyXmldsig(xml, responseSignature, opts.idpCertPem);
407
+ if (signed.refId !== _attr(root, "ID")) {
408
+ throw new AuthError("auth-saml/signed-different-element",
409
+ "verifyResponse: response signature references a different element ID");
410
+ }
411
+ }
412
+
413
+ // Issuer must match the configured IdP entityID
414
+ var issuerEl = _findChild(assertion, "Issuer", SAML_NS.assertion);
415
+ var issuer = _textContent(issuerEl);
416
+ if (issuer !== opts.idpEntityId) {
417
+ throw new AuthError("auth-saml/wrong-issuer",
418
+ "verifyResponse: Assertion Issuer \"" + issuer + "\" does not match expected \"" +
419
+ opts.idpEntityId + "\"");
420
+ }
421
+
422
+ // Subject + SubjectConfirmation
423
+ var subject = _findChild(assertion, "Subject", SAML_NS.assertion);
424
+ if (!subject) throw new AuthError("auth-saml/no-subject", "verifyResponse: missing Subject");
425
+ var nameIdEl = _findChild(subject, "NameID", SAML_NS.assertion);
426
+ if (!nameIdEl) throw new AuthError("auth-saml/no-nameid", "verifyResponse: missing NameID");
427
+ var nameId = _textContent(nameIdEl);
428
+ var nameIdFormat = _attr(nameIdEl, "Format");
429
+
430
+ var nowSec = Math.floor((vopts.now || Date.now()) / 1000); // allow:raw-byte-literal — ms→s
431
+ var confirmations = _findAllChildren(subject, "SubjectConfirmation", SAML_NS.assertion);
432
+ var bearerOk = false;
433
+ for (var i = 0; i < confirmations.length; i++) {
434
+ var sc = confirmations[i];
435
+ if (_attr(sc, "Method") !== "urn:oasis:names:tc:SAML:2.0:cm:bearer") continue;
436
+ var scd = _findChild(sc, "SubjectConfirmationData", SAML_NS.assertion);
437
+ if (!scd) continue;
438
+ var notOnOrAfter = _attr(scd, "NotOnOrAfter");
439
+ if (notOnOrAfter) {
440
+ var t = Date.parse(notOnOrAfter) / 1000; // allow:raw-byte-literal — ms→s
441
+ if (!isFinite(t) || t < nowSec - clockSkewSec) {
442
+ continue; // expired confirmation — try next
443
+ }
444
+ }
445
+ var notBefore = _attr(scd, "NotBefore");
446
+ if (notBefore) {
447
+ var nb = Date.parse(notBefore) / 1000; // allow:raw-byte-literal — ms→s
448
+ if (isFinite(nb) && nb > nowSec + clockSkewSec) continue;
449
+ }
450
+ var recipient = _attr(scd, "Recipient");
451
+ if (recipient && recipient !== opts.assertionConsumerServiceUrl) {
452
+ continue;
453
+ }
454
+ var inResponseTo = _attr(scd, "InResponseTo");
455
+ if (vopts.expectedInResponseTo && inResponseTo !== vopts.expectedInResponseTo) {
456
+ throw new AuthError("auth-saml/bad-in-response-to",
457
+ "SubjectConfirmation InResponseTo \"" + inResponseTo +
458
+ "\" does not match expected \"" + vopts.expectedInResponseTo + "\" (replay defense)");
459
+ }
460
+ bearerOk = true;
461
+ break;
462
+ }
463
+ if (!bearerOk) {
464
+ throw new AuthError("auth-saml/no-valid-bearer",
465
+ "verifyResponse: no Bearer SubjectConfirmation passed time/recipient checks");
466
+ }
467
+
468
+ // Conditions
469
+ var conditions = _findChild(assertion, "Conditions", SAML_NS.assertion);
470
+ if (conditions) {
471
+ var cNotBefore = _attr(conditions, "NotBefore");
472
+ var cNotOnOrAfter = _attr(conditions, "NotOnOrAfter");
473
+ if (cNotBefore) {
474
+ var cnb = Date.parse(cNotBefore) / 1000; // allow:raw-byte-literal — ms→s
475
+ if (isFinite(cnb) && cnb > nowSec + clockSkewSec) {
476
+ throw new AuthError("auth-saml/conditions-not-yet-valid",
477
+ "Conditions NotBefore is in the future");
478
+ }
479
+ }
480
+ if (cNotOnOrAfter) {
481
+ var cnoa = Date.parse(cNotOnOrAfter) / 1000; // allow:raw-byte-literal — ms→s
482
+ if (isFinite(cnoa) && cnoa < nowSec - clockSkewSec) {
483
+ throw new AuthError("auth-saml/conditions-expired",
484
+ "Conditions NotOnOrAfter has passed");
485
+ }
486
+ }
487
+ var ar = _findChild(conditions, "AudienceRestriction", SAML_NS.assertion);
488
+ if (ar) {
489
+ var audiences = _findAllChildren(ar, "Audience", SAML_NS.assertion).map(_textContent);
490
+ if (audiences.indexOf(audience) === -1) {
491
+ throw new AuthError("auth-saml/wrong-audience",
492
+ "Audience \"" + audience + "\" not in assertion's AudienceRestriction (got " +
493
+ JSON.stringify(audiences) + ")");
494
+ }
495
+ }
496
+ }
497
+
498
+ // AuthnStatement.SessionIndex (for SLO)
499
+ var sessionIndex = null;
500
+ var authnStmt = _findChild(assertion, "AuthnStatement", SAML_NS.assertion);
501
+ if (authnStmt) {
502
+ sessionIndex = _attr(authnStmt, "SessionIndex");
503
+ }
504
+
505
+ // AttributeStatement → flat map
506
+ var attributes = {};
507
+ var attrStmt = _findChild(assertion, "AttributeStatement", SAML_NS.assertion);
508
+ if (attrStmt) {
509
+ var attrEls = _findAllChildren(attrStmt, "Attribute", SAML_NS.assertion);
510
+ for (var ai = 0; ai < attrEls.length; ai++) {
511
+ var n = _attr(attrEls[ai], "Name");
512
+ var values = _findAllChildren(attrEls[ai], "AttributeValue", SAML_NS.assertion).map(_textContent);
513
+ attributes[n] = values.length === 1 ? values[0] : values;
514
+ }
515
+ }
516
+
517
+ _emitAudit("response_verified", "success", { issuer: issuer });
518
+ _emitMetric("response-verified");
519
+ return {
520
+ nameId: nameId,
521
+ nameIdFormat: nameIdFormat,
522
+ sessionIndex: sessionIndex,
523
+ attributes: attributes,
524
+ audience: audience,
525
+ inResponseTo: bearerOk ? _attr(_findChild(_findChild(subject, "SubjectConfirmation", SAML_NS.assertion),
526
+ "SubjectConfirmationData", SAML_NS.assertion), "InResponseTo") : null,
527
+ issuer: issuer,
528
+ };
529
+ }
530
+
531
+ /**
532
+ * @primitive b.auth.saml.sp.metadata
533
+ * @signature b.auth.saml.sp.metadata()
534
+ * @since 0.8.62
535
+ *
536
+ * Emit the SP's `EntityDescriptor` XML for IdP-side configuration.
537
+ * Operators serve this verbatim at /saml/metadata.
538
+ *
539
+ * @example
540
+ * app.get("/saml/metadata", function (req, res) {
541
+ * res.setHeader("Content-Type", "application/samlmetadata+xml");
542
+ * res.end(sp.metadata());
543
+ * });
544
+ */
545
+ function metadata() {
546
+ return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" +
547
+ "<md:EntityDescriptor xmlns:md=\"" + SAML_NS.metadata + "\" entityID=\"" + opts.entityId + "\">" +
548
+ "<md:SPSSODescriptor protocolSupportEnumeration=\"" + SAML_NS.protocol + "\" " +
549
+ "AuthnRequestsSigned=\"false\" WantAssertionsSigned=\"true\">" +
550
+ "<md:AssertionConsumerService " +
551
+ "Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST\" " +
552
+ "Location=\"" + opts.assertionConsumerServiceUrl + "\" index=\"0\"/>" +
553
+ "</md:SPSSODescriptor>" +
554
+ "</md:EntityDescriptor>";
555
+ }
556
+
557
+ return {
558
+ buildAuthnRequest: buildAuthnRequest,
559
+ verifyResponse: verifyResponse,
560
+ metadata: metadata,
561
+ entityId: opts.entityId,
562
+ idpEntityId: opts.idpEntityId,
563
+ };
564
+ }
565
+
566
+ /**
567
+ * @primitive b.auth.saml.fetchMdq
568
+ * @signature b.auth.saml.fetchMdq(opts)
569
+ * @since 0.8.62
570
+ * @status stable
571
+ * @related b.auth.saml.sp.create, b.network.tls.checkServerIdentity9525
572
+ *
573
+ * Fetch an entity's signed metadata from a Metadata Query (MDQ)
574
+ * server per SAML 2.0 MDQ. Composes b.httpClient with strict server-
575
+ * identity (RFC 9525) and verifies the metadata XMLDSig against the
576
+ * operator-supplied trust cert. Returns the raw metadata XML on
577
+ * success.
578
+ *
579
+ * The MDQ URL pattern is `<baseUrl>/entities/{sha1(entityId)}` per
580
+ * the spec — operators with a federation MDQ deployment supply the
581
+ * baseUrl + their pinned trust cert.
582
+ *
583
+ * @opts
584
+ * {
585
+ * baseUrl: string,
586
+ * entityId: string,
587
+ * trustCertPem?: string, // PEM of the federation operator's signing cert
588
+ * }
589
+ *
590
+ * @example
591
+ * var xml = await b.auth.saml.fetchMdq({
592
+ * baseUrl: "https://mdq.federation.example",
593
+ * entityId: "https://idp.example",
594
+ * trustCertPem: process.env.FEDERATION_TRUST_CERT_PEM,
595
+ * });
596
+ */
597
+ async function fetchMdq(opts) {
598
+ validateOpts.requireObject(opts, "auth.saml.fetchMdq", AuthError);
599
+ validateOpts.requireNonEmptyString(opts.baseUrl, "baseUrl", AuthError, "auth-saml/no-mdq-base");
600
+ validateOpts.requireNonEmptyString(opts.entityId, "entityId", AuthError, "auth-saml/no-mdq-entity");
601
+ var hash = nodeCrypto.createHash("sha1").update(opts.entityId, "utf8").digest("hex");
602
+ var url = opts.baseUrl.replace(/\/$/, "") + "/entities/%7Bsha1%7D" + hash;
603
+ var hc = httpClient();
604
+ var res = await hc.request({
605
+ url: url,
606
+ method: "GET",
607
+ headers: { Accept: "application/samlmetadata+xml" },
608
+ });
609
+ if (res.statusCode < 200 || res.statusCode >= 300) {
610
+ throw new AuthError("auth-saml/mdq-fetch-failed",
611
+ "fetchMdq " + url + " returned " + res.statusCode);
612
+ }
613
+ if (!res.body || res.body.length === 0) {
614
+ throw new AuthError("auth-saml/mdq-empty",
615
+ "fetchMdq " + url + " returned empty body");
616
+ }
617
+ var xml = res.body.toString("utf8");
618
+ if (opts.trustCertPem) {
619
+ var c14n = xmlC14n();
620
+ var root = c14n.parse(xml);
621
+ var sig = _findChild(root, "Signature");
622
+ if (!sig) {
623
+ throw new AuthError("auth-saml/mdq-unsigned",
624
+ "fetchMdq: metadata is unsigned but trustCertPem was supplied");
625
+ }
626
+ _verifyXmldsig(xml, sig, opts.trustCertPem);
627
+ }
628
+ _emitAudit("mdq_fetched", "success", { entityId: opts.entityId });
629
+ _emitMetric("mdq-fetched");
630
+ return xml;
631
+ }
632
+
633
+ module.exports = {
634
+ sp: { create: create },
635
+ fetchMdq: fetchMdq,
636
+ };