@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,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
+ };