@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.
- package/CHANGELOG.md +4 -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
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.auth.openidFederation
|
|
4
|
+
* @nav Identity
|
|
5
|
+
* @title OpenID Federation 1.0
|
|
6
|
+
* @order 360
|
|
7
|
+
* @card OpenID Federation 1.0 trust-chain primitive — fetches +
|
|
8
|
+
* verifies a chain of entity statements from a leaf (RP /
|
|
9
|
+
* OP / wallet) up to one of the operator's trust anchors,
|
|
10
|
+
* then applies the federation's metadata policy to produce
|
|
11
|
+
* the effective metadata for the leaf.
|
|
12
|
+
*
|
|
13
|
+
* @intro
|
|
14
|
+
* OpenID Federation 1.0 (OIDF) replaces ad-hoc client registration
|
|
15
|
+
* with a JWS-signed delegation chain. Every entity in the federation
|
|
16
|
+
* publishes an *entity configuration* at
|
|
17
|
+
* `<entity_id>/.well-known/openid-federation` (a self-signed JWT
|
|
18
|
+
* listing the entity's keys + metadata + which superiors are
|
|
19
|
+
* allowed to sign subordinate statements about it via
|
|
20
|
+
* `authority_hints`).
|
|
21
|
+
*
|
|
22
|
+
* Each *intermediate* publishes *subordinate statements* signed
|
|
23
|
+
* over the entity directly below — these statements pin the
|
|
24
|
+
* subordinate's JWKS plus an optional `metadata_policy` that
|
|
25
|
+
* adjusts the subordinate's claimed metadata (default values,
|
|
26
|
+
* required claims, allowed-value sets, etc.). The *trust anchor*
|
|
27
|
+
* sits at the top — its public key is operator-configured (out-of-
|
|
28
|
+
* band, baked into the deployment).
|
|
29
|
+
*
|
|
30
|
+
* The verifier walks: leaf entity-config → leaf's authority_hints
|
|
31
|
+
* → fetch subordinate-statement-about-leaf from each authority →
|
|
32
|
+
* verify the JWS using that authority's keys → ascend to that
|
|
33
|
+
* authority's entity config → repeat until a trust anchor is
|
|
34
|
+
* reached. The chain must close at a trust anchor; a chain that
|
|
35
|
+
* doesn't is refused.
|
|
36
|
+
*
|
|
37
|
+
* Surface:
|
|
38
|
+
*
|
|
39
|
+
* b.auth.openidFederation.parseEntityStatement(jwt) → claims
|
|
40
|
+
* b.auth.openidFederation.verifyEntityStatement(jwt, jwks) → claims
|
|
41
|
+
* b.auth.openidFederation.buildTrustChain({ leafEntityId, trustAnchors, fetcher? })
|
|
42
|
+
* → [{jwt, claims, role}] (leaf-first)
|
|
43
|
+
* b.auth.openidFederation.applyMetadataPolicy(metadata, chain) → effective metadata
|
|
44
|
+
* b.auth.openidFederation.resolveLeaf({ leafEntityId, trustAnchors, ... })
|
|
45
|
+
* → { effectiveMetadata, chain, trustAnchor }
|
|
46
|
+
*
|
|
47
|
+
* The framework does NOT publish entity configurations — that's a
|
|
48
|
+
* route the operator's RP code stands up. Verification + chain
|
|
49
|
+
* construction is the framework's job; serving is operator-side.
|
|
50
|
+
*
|
|
51
|
+
* Metadata-policy operators implemented (per OpenID Federation 1.0
|
|
52
|
+
* §6.2): value, add, default, one_of, subset_of, superset_of,
|
|
53
|
+
* essential. Unknown operators refuse loudly so a misconfigured
|
|
54
|
+
* policy doesn't silently let unauthorized metadata through.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
var lazyRequire = require("../lazy-require");
|
|
58
|
+
var validateOpts = require("../validate-opts");
|
|
59
|
+
var safeJson = require("../safe-json");
|
|
60
|
+
var nodeCrypto = require("node:crypto");
|
|
61
|
+
var { AuthError } = require("../framework-error");
|
|
62
|
+
|
|
63
|
+
var httpClient = lazyRequire(function () { return require("../http-client"); });
|
|
64
|
+
var audit = lazyRequire(function () { return require("../audit"); });
|
|
65
|
+
var observability = lazyRequire(function () { return require("../observability"); });
|
|
66
|
+
var emit = validateOpts.makeNamespacedEmitters("auth.openidFederation", { audit: audit, observability: observability });
|
|
67
|
+
|
|
68
|
+
var _emitAudit = emit.audit;
|
|
69
|
+
var _emitMetric = emit.metric;
|
|
70
|
+
|
|
71
|
+
var SUPPORTED_ALGS = ["ES256", "ES384", "ES512", "PS256", "PS384", "PS512", "RS256", "EdDSA"];
|
|
72
|
+
var MAX_STATEMENT_BYTES = 64 * 1024; // allow:raw-byte-literal — entity-statement size cap
|
|
73
|
+
var MAX_CHAIN_DEPTH = 10; // allow:raw-byte-literal — federation chain depth ceiling
|
|
74
|
+
|
|
75
|
+
function _b64uDecodeStr(s) { return Buffer.from(s, "base64url").toString("utf8"); }
|
|
76
|
+
|
|
77
|
+
function _hashByAlg(alg) {
|
|
78
|
+
return { ES256: "sha256", ES384: "sha384", ES512: "sha512",
|
|
79
|
+
PS256: "sha256", PS384: "sha384", PS512: "sha512",
|
|
80
|
+
RS256: "sha256", EdDSA: null }[alg];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @primitive b.auth.openidFederation.parseEntityStatement
|
|
85
|
+
* @signature b.auth.openidFederation.parseEntityStatement(jwt)
|
|
86
|
+
* @since 0.8.62
|
|
87
|
+
*
|
|
88
|
+
* Decode (without verifying) an entity statement / configuration
|
|
89
|
+
* JWT and return its header + claims. Used to look up the right
|
|
90
|
+
* verification key BEFORE the signature check.
|
|
91
|
+
*
|
|
92
|
+
* @example
|
|
93
|
+
* var parsed = b.auth.openidFederation.parseEntityStatement(entityConfigJwt);
|
|
94
|
+
* // → { header: { typ, alg, kid }, claims: { iss, sub, iat, exp, jwks, ... } }
|
|
95
|
+
*/
|
|
96
|
+
function parseEntityStatement(jwt) {
|
|
97
|
+
if (typeof jwt !== "string" || jwt.length === 0 || jwt.length > MAX_STATEMENT_BYTES) {
|
|
98
|
+
throw new AuthError("auth-openid-federation/bad-statement",
|
|
99
|
+
"entity statement empty or exceeds " + MAX_STATEMENT_BYTES + " bytes");
|
|
100
|
+
}
|
|
101
|
+
var parts = jwt.split(".");
|
|
102
|
+
if (parts.length !== 3) {
|
|
103
|
+
throw new AuthError("auth-openid-federation/malformed",
|
|
104
|
+
"entity statement must be a 3-segment JWS");
|
|
105
|
+
}
|
|
106
|
+
var header, payload;
|
|
107
|
+
try {
|
|
108
|
+
header = safeJson.parse(_b64uDecodeStr(parts[0]), { maxBytes: 4096 }); // allow:raw-byte-literal — header cap
|
|
109
|
+
payload = safeJson.parse(_b64uDecodeStr(parts[1]), { maxBytes: MAX_STATEMENT_BYTES });
|
|
110
|
+
} catch (e) {
|
|
111
|
+
throw new AuthError("auth-openid-federation/bad-decode",
|
|
112
|
+
"entity statement decode failed: " + ((e && e.message) || String(e)));
|
|
113
|
+
}
|
|
114
|
+
if (header.typ !== "entity-statement+jwt") {
|
|
115
|
+
throw new AuthError("auth-openid-federation/wrong-typ",
|
|
116
|
+
"entity statement header.typ must be \"entity-statement+jwt\" (got \"" + header.typ + "\")");
|
|
117
|
+
}
|
|
118
|
+
if (!header.alg || SUPPORTED_ALGS.indexOf(header.alg) === -1) {
|
|
119
|
+
throw new AuthError("auth-openid-federation/unsupported-alg",
|
|
120
|
+
"entity statement alg \"" + header.alg + "\" not supported");
|
|
121
|
+
}
|
|
122
|
+
return { header: header, claims: payload, parts: parts };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* @primitive b.auth.openidFederation.verifyEntityStatement
|
|
127
|
+
* @signature b.auth.openidFederation.verifyEntityStatement(jwt, jwks)
|
|
128
|
+
* @since 0.8.62
|
|
129
|
+
*
|
|
130
|
+
* Verify a single entity statement's JWS signature using the
|
|
131
|
+
* provided JWKS. Returns the parsed claims on success; throws on
|
|
132
|
+
* any failure (malformed / wrong typ / unsupported alg / no
|
|
133
|
+
* matching kid / bad signature / iat-future / expired).
|
|
134
|
+
*
|
|
135
|
+
* @example
|
|
136
|
+
* var claims = b.auth.openidFederation.verifyEntityStatement(jwt, anchorJwks);
|
|
137
|
+
* // → { iss, sub, iat, exp, jwks, metadata, authority_hints, ... }
|
|
138
|
+
*/
|
|
139
|
+
function verifyEntityStatement(jwt, jwks) {
|
|
140
|
+
var parsed = parseEntityStatement(jwt);
|
|
141
|
+
if (!jwks || !Array.isArray(jwks.keys) || jwks.keys.length === 0) {
|
|
142
|
+
throw new AuthError("auth-openid-federation/no-keys",
|
|
143
|
+
"verifyEntityStatement: jwks must include a keys[] array");
|
|
144
|
+
}
|
|
145
|
+
var key = null;
|
|
146
|
+
if (parsed.header.kid) {
|
|
147
|
+
for (var i = 0; i < jwks.keys.length; i++) {
|
|
148
|
+
if (jwks.keys[i].kid === parsed.header.kid) { key = jwks.keys[i]; break; }
|
|
149
|
+
}
|
|
150
|
+
} else if (jwks.keys.length === 1) {
|
|
151
|
+
key = jwks.keys[0];
|
|
152
|
+
}
|
|
153
|
+
if (!key) {
|
|
154
|
+
throw new AuthError("auth-openid-federation/no-matching-kid",
|
|
155
|
+
"verifyEntityStatement: no JWKS key matches kid \"" + parsed.header.kid + "\"");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
var keyObj;
|
|
159
|
+
try { keyObj = nodeCrypto.createPublicKey({ key: key, format: "jwk" }); }
|
|
160
|
+
catch (e) {
|
|
161
|
+
throw new AuthError("auth-openid-federation/bad-jwk",
|
|
162
|
+
"verifyEntityStatement: JWK is not parseable: " + ((e && e.message) || String(e)));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
var hash = _hashByAlg(parsed.header.alg);
|
|
166
|
+
var verifyOpts = { key: keyObj };
|
|
167
|
+
if (parsed.header.alg.indexOf("ES") === 0) verifyOpts.dsaEncoding = "ieee-p1363";
|
|
168
|
+
if (parsed.header.alg.indexOf("PS") === 0) {
|
|
169
|
+
verifyOpts.padding = nodeCrypto.constants.RSA_PKCS1_PSS_PADDING;
|
|
170
|
+
verifyOpts.saltLength = nodeCrypto.constants.RSA_PSS_SALTLEN_DIGEST;
|
|
171
|
+
}
|
|
172
|
+
var signingInput = parsed.parts[0] + "." + parsed.parts[1];
|
|
173
|
+
var sig = Buffer.from(parsed.parts[2], "base64url");
|
|
174
|
+
var ok = nodeCrypto.verify(hash, Buffer.from(signingInput, "ascii"), verifyOpts, sig);
|
|
175
|
+
if (!ok) {
|
|
176
|
+
throw new AuthError("auth-openid-federation/bad-signature",
|
|
177
|
+
"verifyEntityStatement: signature verification failed");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
var nowSec = Math.floor(Date.now() / 1000); // allow:raw-byte-literal — ms→s
|
|
181
|
+
var skew = 60; // allow:raw-time-literal — clock-skew tolerance 60s
|
|
182
|
+
if (typeof parsed.claims.iat !== "number" || parsed.claims.iat > nowSec + skew) {
|
|
183
|
+
throw new AuthError("auth-openid-federation/iat-future",
|
|
184
|
+
"verifyEntityStatement: iat is in the future or missing");
|
|
185
|
+
}
|
|
186
|
+
if (typeof parsed.claims.exp !== "number" || parsed.claims.exp < nowSec - skew) {
|
|
187
|
+
throw new AuthError("auth-openid-federation/expired",
|
|
188
|
+
"verifyEntityStatement: statement expired");
|
|
189
|
+
}
|
|
190
|
+
if (typeof parsed.claims.iss !== "string" || typeof parsed.claims.sub !== "string") {
|
|
191
|
+
throw new AuthError("auth-openid-federation/missing-iss-sub",
|
|
192
|
+
"verifyEntityStatement: iss + sub required");
|
|
193
|
+
}
|
|
194
|
+
return parsed.claims;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Apply a single metadata_policy block to a metadata object and
|
|
199
|
+
* return the resulting object. Pure — never mutates input.
|
|
200
|
+
*
|
|
201
|
+
* Operators per OIDF §6.2.1:
|
|
202
|
+
* value — set claim to a fixed value (overrides subordinate)
|
|
203
|
+
* add — array claim: append values not already present
|
|
204
|
+
* default — provide a default if the claim is absent
|
|
205
|
+
* one_of — claim must be one of the listed values (else throw)
|
|
206
|
+
* subset_of — claim's array values must be a subset of the listed (else throw)
|
|
207
|
+
* superset_of — claim's array values must include every listed value (else throw)
|
|
208
|
+
* essential — claim must be present (else throw)
|
|
209
|
+
*/
|
|
210
|
+
function _applyOnePolicy(metadata, policy) {
|
|
211
|
+
var out = Object.assign({}, metadata);
|
|
212
|
+
Object.keys(policy).forEach(function (claimName) {
|
|
213
|
+
var rules = policy[claimName];
|
|
214
|
+
if (!rules || typeof rules !== "object") {
|
|
215
|
+
throw new AuthError("auth-openid-federation/bad-policy-rules",
|
|
216
|
+
"metadata_policy['" + claimName + "'] must be an object");
|
|
217
|
+
}
|
|
218
|
+
Object.keys(rules).forEach(function (op) {
|
|
219
|
+
var v = rules[op];
|
|
220
|
+
switch (op) {
|
|
221
|
+
case "value":
|
|
222
|
+
out[claimName] = v;
|
|
223
|
+
break;
|
|
224
|
+
case "default":
|
|
225
|
+
if (out[claimName] === undefined) out[claimName] = v;
|
|
226
|
+
break;
|
|
227
|
+
case "add":
|
|
228
|
+
if (!Array.isArray(v)) {
|
|
229
|
+
throw new AuthError("auth-openid-federation/bad-policy-add",
|
|
230
|
+
"metadata_policy['" + claimName + "'].add requires an array");
|
|
231
|
+
}
|
|
232
|
+
if (!Array.isArray(out[claimName])) out[claimName] = [];
|
|
233
|
+
v.forEach(function (val) { if (out[claimName].indexOf(val) === -1) out[claimName].push(val); });
|
|
234
|
+
break;
|
|
235
|
+
case "one_of":
|
|
236
|
+
if (!Array.isArray(v)) {
|
|
237
|
+
throw new AuthError("auth-openid-federation/bad-policy-one-of",
|
|
238
|
+
"metadata_policy['" + claimName + "'].one_of requires an array");
|
|
239
|
+
}
|
|
240
|
+
if (out[claimName] !== undefined && v.indexOf(out[claimName]) === -1) {
|
|
241
|
+
throw new AuthError("auth-openid-federation/policy-one-of-failed",
|
|
242
|
+
"metadata_policy['" + claimName + "'].one_of: value \"" +
|
|
243
|
+
JSON.stringify(out[claimName]) + "\" not in " + JSON.stringify(v));
|
|
244
|
+
}
|
|
245
|
+
break;
|
|
246
|
+
case "subset_of":
|
|
247
|
+
if (!Array.isArray(v)) {
|
|
248
|
+
throw new AuthError("auth-openid-federation/bad-policy-subset-of",
|
|
249
|
+
"metadata_policy['" + claimName + "'].subset_of requires an array");
|
|
250
|
+
}
|
|
251
|
+
if (Array.isArray(out[claimName])) {
|
|
252
|
+
out[claimName].forEach(function (val) {
|
|
253
|
+
if (v.indexOf(val) === -1) {
|
|
254
|
+
throw new AuthError("auth-openid-federation/policy-subset-of-failed",
|
|
255
|
+
"metadata_policy['" + claimName + "']: value \"" + JSON.stringify(val) +
|
|
256
|
+
"\" not in subset_of " + JSON.stringify(v));
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
break;
|
|
261
|
+
case "superset_of":
|
|
262
|
+
if (!Array.isArray(v)) {
|
|
263
|
+
throw new AuthError("auth-openid-federation/bad-policy-superset-of",
|
|
264
|
+
"metadata_policy['" + claimName + "'].superset_of requires an array");
|
|
265
|
+
}
|
|
266
|
+
v.forEach(function (req) {
|
|
267
|
+
if (!Array.isArray(out[claimName]) || out[claimName].indexOf(req) === -1) {
|
|
268
|
+
throw new AuthError("auth-openid-federation/policy-superset-of-failed",
|
|
269
|
+
"metadata_policy['" + claimName + "']: missing required value \"" + req + "\"");
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
break;
|
|
273
|
+
case "essential":
|
|
274
|
+
if (v === true && (out[claimName] === undefined || out[claimName] === null)) {
|
|
275
|
+
throw new AuthError("auth-openid-federation/policy-essential-failed",
|
|
276
|
+
"metadata_policy['" + claimName + "'].essential=true but claim is absent");
|
|
277
|
+
}
|
|
278
|
+
break;
|
|
279
|
+
default:
|
|
280
|
+
throw new AuthError("auth-openid-federation/unknown-policy-op",
|
|
281
|
+
"metadata_policy['" + claimName + "'] unknown operator \"" + op + "\"");
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
return out;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* @primitive b.auth.openidFederation.applyMetadataPolicy
|
|
290
|
+
* @signature b.auth.openidFederation.applyMetadataPolicy(metadata, chain, kind)
|
|
291
|
+
* @since 0.8.62
|
|
292
|
+
*
|
|
293
|
+
* Apply every metadata_policy in the chain (top-down) to the leaf's
|
|
294
|
+
* declared metadata for the given entity-kind ("openid_relying_party"
|
|
295
|
+
* / "openid_provider" / "federation_entity" / etc.) and return the
|
|
296
|
+
* effective metadata. Throws on any policy violation.
|
|
297
|
+
*
|
|
298
|
+
* The chain is leaf-first; we reverse for top-down application so
|
|
299
|
+
* the trust anchor's policy applies first, then each intermediate's,
|
|
300
|
+
* then the leaf's claimed metadata is the starting object.
|
|
301
|
+
*
|
|
302
|
+
* @example
|
|
303
|
+
* var effective = b.auth.openidFederation.applyMetadataPolicy(
|
|
304
|
+
* leafClaims.metadata.openid_relying_party,
|
|
305
|
+
* chain,
|
|
306
|
+
* "openid_relying_party"
|
|
307
|
+
* );
|
|
308
|
+
* // → metadata with default / one_of / subset_of constraints applied
|
|
309
|
+
*/
|
|
310
|
+
function applyMetadataPolicy(metadata, chain, kind) {
|
|
311
|
+
if (!metadata || typeof metadata !== "object") {
|
|
312
|
+
throw new AuthError("auth-openid-federation/bad-metadata",
|
|
313
|
+
"applyMetadataPolicy: metadata must be an object");
|
|
314
|
+
}
|
|
315
|
+
if (!Array.isArray(chain)) {
|
|
316
|
+
throw new AuthError("auth-openid-federation/bad-chain",
|
|
317
|
+
"applyMetadataPolicy: chain must be an array");
|
|
318
|
+
}
|
|
319
|
+
var out = Object.assign({}, metadata);
|
|
320
|
+
// Walk top-down (anchor last in leaf-first array).
|
|
321
|
+
for (var i = chain.length - 1; i >= 0; i--) {
|
|
322
|
+
var stmt = chain[i];
|
|
323
|
+
if (!stmt || !stmt.claims) continue;
|
|
324
|
+
if (stmt.claims.metadata_policy && stmt.claims.metadata_policy[kind]) {
|
|
325
|
+
out = _applyOnePolicy(out, stmt.claims.metadata_policy[kind]);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return out;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function _defaultFetcher(url) {
|
|
332
|
+
var hc = httpClient();
|
|
333
|
+
var res = await hc.request({ url: url, method: "GET", headers: { Accept: "application/entity-statement+jwt" } });
|
|
334
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
335
|
+
throw new AuthError("auth-openid-federation/fetch-failed",
|
|
336
|
+
"fetch " + url + " returned " + res.statusCode);
|
|
337
|
+
}
|
|
338
|
+
if (!res.body) {
|
|
339
|
+
throw new AuthError("auth-openid-federation/empty-response",
|
|
340
|
+
"fetch " + url + " returned empty body");
|
|
341
|
+
}
|
|
342
|
+
return res.body.toString("utf8");
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* @primitive b.auth.openidFederation.buildTrustChain
|
|
347
|
+
* @signature b.auth.openidFederation.buildTrustChain(opts)
|
|
348
|
+
* @since 0.8.62
|
|
349
|
+
*
|
|
350
|
+
* Construct + verify a leaf-to-anchor trust chain.
|
|
351
|
+
*
|
|
352
|
+
* @opts
|
|
353
|
+
* {
|
|
354
|
+
* leafEntityId: string, // the entity to verify
|
|
355
|
+
* trustAnchors: { [entityId]: jwks }, // operator-configured anchors
|
|
356
|
+
* fetcher?: async fn(url)→jwt, // override the default httpClient fetch
|
|
357
|
+
* fetchSubordinate?: async fn(authority, sub)→jwt, // optional explicit fetcher; default = `<authority>/fetch?iss=<authority>&sub=<sub>`
|
|
358
|
+
* maxDepth?: number, // chain cap (default 10)
|
|
359
|
+
* }
|
|
360
|
+
*
|
|
361
|
+
* Returns `[{ jwt, claims, role }]` leaf-first. Each element has
|
|
362
|
+
* `role` ∈ {"leaf", "intermediate", "trust_anchor"}.
|
|
363
|
+
*
|
|
364
|
+
* @example
|
|
365
|
+
* var chain = await b.auth.openidFederation.buildTrustChain({
|
|
366
|
+
* leafEntityId: "https://rp.example",
|
|
367
|
+
* trustAnchors: { "https://anchor.example": anchorJwks },
|
|
368
|
+
* });
|
|
369
|
+
* // → [{ role: "leaf", ... }, { role: "intermediate", ... }, { role: "trust_anchor", ... }]
|
|
370
|
+
*/
|
|
371
|
+
async function buildTrustChain(opts) {
|
|
372
|
+
validateOpts.requireObject(opts, "buildTrustChain", AuthError);
|
|
373
|
+
validateOpts.requireNonEmptyString(opts.leafEntityId, "leafEntityId", AuthError, "auth-openid-federation/no-leaf");
|
|
374
|
+
if (!opts.trustAnchors || typeof opts.trustAnchors !== "object" || Object.keys(opts.trustAnchors).length === 0) {
|
|
375
|
+
throw new AuthError("auth-openid-federation/no-anchors",
|
|
376
|
+
"buildTrustChain: trustAnchors must be a non-empty { entityId: jwks } map");
|
|
377
|
+
}
|
|
378
|
+
var fetcher = opts.fetcher || _defaultFetcher;
|
|
379
|
+
var fetchSubordinate = opts.fetchSubordinate || async function (authority, sub) {
|
|
380
|
+
var disc = await fetcher(authority.replace(/\/$/, "") + "/.well-known/openid-federation");
|
|
381
|
+
var entityCfg = parseEntityStatement(disc).claims;
|
|
382
|
+
if (typeof entityCfg.federation_fetch_endpoint !== "string") {
|
|
383
|
+
throw new AuthError("auth-openid-federation/no-fetch-endpoint",
|
|
384
|
+
"authority \"" + authority + "\" has no federation_fetch_endpoint");
|
|
385
|
+
}
|
|
386
|
+
var url = entityCfg.federation_fetch_endpoint + "?iss=" +
|
|
387
|
+
encodeURIComponent(authority) + "&sub=" + encodeURIComponent(sub);
|
|
388
|
+
return await fetcher(url);
|
|
389
|
+
};
|
|
390
|
+
var maxDepth = opts.maxDepth || MAX_CHAIN_DEPTH;
|
|
391
|
+
|
|
392
|
+
var chain = [];
|
|
393
|
+
var current = opts.leafEntityId;
|
|
394
|
+
var depth = 0;
|
|
395
|
+
while (depth < maxDepth) {
|
|
396
|
+
var entityConfigUrl = current.replace(/\/$/, "") + "/.well-known/openid-federation";
|
|
397
|
+
var entityConfigJwt = await fetcher(entityConfigUrl);
|
|
398
|
+
var parsedEC = parseEntityStatement(entityConfigJwt);
|
|
399
|
+
if (parsedEC.claims.iss !== current || parsedEC.claims.sub !== current) {
|
|
400
|
+
throw new AuthError("auth-openid-federation/bad-self-statement",
|
|
401
|
+
"entity configuration for \"" + current + "\" must have iss==sub==entity_id");
|
|
402
|
+
}
|
|
403
|
+
// Self-signed: verify with its own jwks.
|
|
404
|
+
verifyEntityStatement(entityConfigJwt, parsedEC.claims.jwks || {});
|
|
405
|
+
|
|
406
|
+
// Is this entity a trust anchor?
|
|
407
|
+
if (Object.prototype.hasOwnProperty.call(opts.trustAnchors, current)) {
|
|
408
|
+
// Verify the anchor's self-statement using the operator-pinned
|
|
409
|
+
// JWKS — defends against a compromised anchor key by trusting
|
|
410
|
+
// the configured one over what the anchor publishes today.
|
|
411
|
+
verifyEntityStatement(entityConfigJwt, opts.trustAnchors[current]);
|
|
412
|
+
chain.push({ jwt: entityConfigJwt, claims: parsedEC.claims, role: "trust_anchor" });
|
|
413
|
+
_emitAudit("chain_built", "success", {
|
|
414
|
+
leaf: opts.leafEntityId, depth: chain.length, anchor: current,
|
|
415
|
+
});
|
|
416
|
+
_emitMetric("chain-built");
|
|
417
|
+
return chain;
|
|
418
|
+
}
|
|
419
|
+
// Not the anchor — add to chain, ascend via authority_hints.
|
|
420
|
+
chain.push({
|
|
421
|
+
jwt: entityConfigJwt,
|
|
422
|
+
claims: parsedEC.claims,
|
|
423
|
+
role: depth === 0 ? "leaf" : "intermediate",
|
|
424
|
+
});
|
|
425
|
+
if (!Array.isArray(parsedEC.claims.authority_hints) ||
|
|
426
|
+
parsedEC.claims.authority_hints.length === 0) {
|
|
427
|
+
throw new AuthError("auth-openid-federation/no-authority-hints",
|
|
428
|
+
"entity \"" + current + "\" has no authority_hints; cannot ascend to a trust anchor");
|
|
429
|
+
}
|
|
430
|
+
// Pick the FIRST authority_hint that resolves to a trust anchor,
|
|
431
|
+
// OR the first that returns a valid subordinate statement. Real
|
|
432
|
+
// operators with multiple federations usually have one anchor
|
|
433
|
+
// active; we walk in order and pick the first success.
|
|
434
|
+
var ascended = false;
|
|
435
|
+
for (var ai = 0; ai < parsedEC.claims.authority_hints.length; ai++) {
|
|
436
|
+
var authority = parsedEC.claims.authority_hints[ai];
|
|
437
|
+
try {
|
|
438
|
+
var subordinateJwt = await fetchSubordinate(authority, current);
|
|
439
|
+
var parsedSub = parseEntityStatement(subordinateJwt);
|
|
440
|
+
if (parsedSub.claims.iss !== authority || parsedSub.claims.sub !== current) {
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
// Need to fetch the authority's JWKS to verify the subordinate
|
|
444
|
+
// statement — the authority's entity-config carries it. We
|
|
445
|
+
// verify that on the next loop iteration; for now, refuse if
|
|
446
|
+
// the subordinate's signature doesn't verify with the keys
|
|
447
|
+
// declared in the authority's most recently fetched config.
|
|
448
|
+
var authorityCfgJwt = await fetcher(authority.replace(/\/$/, "") + "/.well-known/openid-federation");
|
|
449
|
+
var authorityCfgClaims = parseEntityStatement(authorityCfgJwt).claims;
|
|
450
|
+
verifyEntityStatement(subordinateJwt, authorityCfgClaims.jwks || {});
|
|
451
|
+
// Replace the entity's claimed JWKS with the JWKS the
|
|
452
|
+
// authority signs about it — this is the trust-bearing one.
|
|
453
|
+
chain[chain.length - 1].claims.jwks = parsedSub.claims.jwks || chain[chain.length - 1].claims.jwks;
|
|
454
|
+
chain[chain.length - 1].subordinateJwt = subordinateJwt;
|
|
455
|
+
chain[chain.length - 1].subordinate = parsedSub.claims;
|
|
456
|
+
current = authority;
|
|
457
|
+
ascended = true;
|
|
458
|
+
break;
|
|
459
|
+
} catch (_e) {
|
|
460
|
+
// Try the next authority_hint.
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
if (!ascended) {
|
|
464
|
+
throw new AuthError("auth-openid-federation/no-ascent",
|
|
465
|
+
"entity \"" + current + "\" has authority_hints but none yielded a verifiable subordinate statement");
|
|
466
|
+
}
|
|
467
|
+
depth += 1;
|
|
468
|
+
}
|
|
469
|
+
throw new AuthError("auth-openid-federation/chain-too-deep",
|
|
470
|
+
"buildTrustChain: max depth " + maxDepth + " exceeded; refused");
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* @primitive b.auth.openidFederation.resolveLeaf
|
|
475
|
+
* @signature b.auth.openidFederation.resolveLeaf(opts)
|
|
476
|
+
* @since 0.8.62
|
|
477
|
+
*
|
|
478
|
+
* One-shot helper: build the trust chain for a leaf entity, apply
|
|
479
|
+
* the federation's metadata policy, and return the effective
|
|
480
|
+
* metadata for the requested entity-kind (`opts.kind` —
|
|
481
|
+
* "openid_relying_party", "openid_provider", "federation_entity",
|
|
482
|
+
* "oauth_resource", etc.). Throws on any chain / policy failure.
|
|
483
|
+
*
|
|
484
|
+
* @opts
|
|
485
|
+
* {
|
|
486
|
+
* leafEntityId: string,
|
|
487
|
+
* trustAnchors: { [entityId: string]: object },
|
|
488
|
+
* kind: string, // e.g. "openid_relying_party"
|
|
489
|
+
* fetcher?: async fn(url) -> jwt,
|
|
490
|
+
* fetchSubordinate?: async fn(authority, sub) -> jwt,
|
|
491
|
+
* maxDepth?: number,
|
|
492
|
+
* }
|
|
493
|
+
*
|
|
494
|
+
* @example
|
|
495
|
+
* var resolved = await b.auth.openidFederation.resolveLeaf({
|
|
496
|
+
* leafEntityId: "https://rp.example",
|
|
497
|
+
* trustAnchors: { "https://anchor.example": anchorJwks },
|
|
498
|
+
* kind: "openid_relying_party",
|
|
499
|
+
* });
|
|
500
|
+
* // → { chain, trustAnchor, effectiveMetadata, leafEntityId }
|
|
501
|
+
*/
|
|
502
|
+
async function resolveLeaf(opts) {
|
|
503
|
+
validateOpts.requireObject(opts, "resolveLeaf", AuthError);
|
|
504
|
+
validateOpts.requireNonEmptyString(opts.kind, "kind", AuthError, "auth-openid-federation/no-kind");
|
|
505
|
+
var chain = await buildTrustChain(opts);
|
|
506
|
+
var leafClaims = chain[0].claims;
|
|
507
|
+
var meta = (leafClaims.metadata && leafClaims.metadata[opts.kind]) || {};
|
|
508
|
+
var effective = applyMetadataPolicy(meta, chain, opts.kind);
|
|
509
|
+
return {
|
|
510
|
+
chain: chain,
|
|
511
|
+
trustAnchor: chain[chain.length - 1].claims.iss,
|
|
512
|
+
effectiveMetadata: effective,
|
|
513
|
+
leafEntityId: opts.leafEntityId,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
module.exports = {
|
|
518
|
+
parseEntityStatement: parseEntityStatement,
|
|
519
|
+
verifyEntityStatement: verifyEntityStatement,
|
|
520
|
+
buildTrustChain: buildTrustChain,
|
|
521
|
+
applyMetadataPolicy: applyMetadataPolicy,
|
|
522
|
+
resolveLeaf: resolveLeaf,
|
|
523
|
+
};
|