@blamejs/core 0.8.59 → 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.
- package/CHANGELOG.md +5 -0
- package/README.md +2 -2
- package/index.js +11 -0
- package/lib/audit.js +1 -0
- package/lib/auth/ciba.js +530 -0
- package/lib/auth/oauth.js +199 -11
- package/lib/auth/oid4vci.js +588 -0
- package/lib/auth/oid4vp.js +514 -0
- package/lib/auth/openid-federation.js +523 -0
- package/lib/auth/saml.js +636 -0
- package/lib/auth/sd-jwt-vc-holder.js +30 -8
- package/lib/auth/sd-jwt-vc.js +61 -7
- package/lib/db-collection.js +402 -105
- package/lib/db-file-lifecycle.js +333 -0
- package/lib/session-stores.js +138 -0
- package/lib/session.js +307 -20
- package/lib/validate-opts.js +41 -0
- package/lib/xml-c14n.js +499 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/lib/auth/saml.js
ADDED
|
@@ -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
|
+
};
|