@blamejs/core 0.9.3 → 0.9.5
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 +2 -0
- package/lib/auth/ciba.js +47 -6
- package/lib/auth/jwt-external.js +17 -4
- package/lib/auth/oauth.js +62 -12
- package/lib/middleware/dpop.js +30 -5
- package/lib/network-tls.js +8 -1
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.9.x
|
|
10
10
|
|
|
11
|
+
- v0.9.5 (2026-05-12) — **Fix-up for v0.9.3 + v0.9.4 audit-derived primitives** (five reported reachability/contract bugs). (1) **`b.middleware.dpop` `trustForwardedHeaders` was unreachable** — the v0.9.4 X-Forwarded-* trust gate added the option to `_reconstructHtu` but the `create()` validateOpts whitelist still rejected unknown keys. Operators behind a trusted reverse proxy got `unknown-option` instead of the documented opt-in, leaving valid DPoP proofs failing htu matching. The whitelist now includes `trustForwardedHeaders`. (2) **`b.auth.jwt.verifyExternal` `allowKidlessJwks` was unreachable** — same shape, fixed the same way. (3) **OAuth `allowKidlessJwks` didn't reach token-exchange flows** — pre-v0.9.5 the opt was per-`verifyIdToken`-call, but `_normalizeTokens()` (called from `exchangeCode` / `pollDeviceCode` / `exchangeToken` / `refreshAccessToken`) passed a reduced `{ nonce, skipNonceCheck }` shape that dropped the operator opt. Surface promoted to client-level: pass `b.auth.oauth.create({ allowKidlessJwks: true })` once and it threads through every code path that lands on the verifier. The per-call `vopts.allowKidlessJwks` continues to work for direct `verifyIdToken` callers. (4) **`b.auth.oauth.refreshAccessToken` `checkAndInsert` return-value contract inverted** — pre-v0.9.5 interpreted `true` as "already seen → replay" but the framework-wide `checkAndInsert` contract (`b.nonceStore`, `b.auth.jwt`) is the opposite: `true` = unseen-and-now-inserted (first sighting), `false` = already-present (replay). Operators reusing an existing `b.nonceStore`-style backend got every first refresh attempt rejected as token theft, breaking normal refresh flows. The handler now normalizes `inserted === false` → `alreadySeen = true`, consistent with the rest of the framework. (5) **`b.auth.ciba` `_intervalState` memory leak on error paths** — pre-v0.9.5 entries were only deleted on successful token issuance; denied / expired auth requests, and ping/push delivery modes that never call `pollToken` successfully, left permanent entries causing unbounded growth in long-running processes. Now entries carry an `expireAtMs` derived from the IdP-supplied `expires_in` of the auth_req_id, and an opportunistic sweep runs on every `_registerInitialInterval` call (no separate timer needed). Terminal CIBA errors (`expired_token` / `access_denied` / `invalid_grant` / `transaction_failed`) also delete the entry immediately on the error path.
|
|
12
|
+
- v0.9.4 (2026-05-12) — **Audit hardening slice 4: kid-less JWKS lookup refusal + OCSP nonce CT compare + OAuth scope strict-split + DPoP `X-Forwarded-Proto` trust gate**. Closes the remaining MEDIUM-tier findings from the 2026-05-11 auth audit. **`b.auth.oauth.verifyIdToken` + `b.auth.jwt.verifyExternal` kid-less JWKS lookup refusal** — pre-v0.9.4 both verifiers fell back to `keys[0]` when the token carried NO `kid` and the JWKS had exactly one key. This is a latent vector during JWKS rotation: an attacker shipping a kid-less token gets the lone-key path during the window the rotated-out key is still cached at the IdP but the rotated-in key is already published. Every modern IdP includes `kid`; the framework now refuses kid-less tokens unconditionally. Operators with non-conforming IdPs that genuinely emit kid-less tokens opt out via `vopts.allowKidlessJwks: true`. **`b.network.tls` OCSP nonce constant-time compare** — `evaluateOcspResponse`'s `expectedNonce` match migrated from `Buffer.equals` to `b.crypto.timingSafeEqual` for module-wide consistency with the Merkle-root / NTS-cookie / cert-fingerprint paths that already use `timingSafeEqual`. **`b.auth.oauth` scope strict whitespace split** — RFC 6749 §3.3 says `scope` is space-separated, ONLY `U+0020`. Pre-v0.9.4 `raw.scope.split(/\s+/)` matched U+0085 NEL, U+00A0 NBSP, etc., so a hostile AS returning `scope: "admin<NEL>read"` would surface as `["admin", "read"]` and the operator's scope allowlist saw two distinct scopes. Now splits on single-space only; empty pieces filtered out. **`b.middleware.dpop` `X-Forwarded-*` trust gate** — `_reconstructHtu` previously read `X-Forwarded-Proto` / `X-Forwarded-Host` unconditionally; an attacker who can hit the origin directly while spoofing `X-Forwarded-Proto: https` could trick the middleware into building an `https` htu that the DPoP proof was signed for, when the origin is actually serving HTTP (RFC 9449 §4.3 says the htu MUST be the absolute URL the request was sent to). The default now derives proto/host from the socket; operators with a confirmed-trusted front proxy opt in via `opts.trustForwardedHeaders: true`.
|
|
11
13
|
- v0.9.3 (2026-05-11) — **Audit hardening slice 3: OAuth + OID4VCI + OID4VP + CIBA + constant-time-compare migrations**. Continues the 2026-05-11 auth audit follow-through. **`b.auth.oauth.refreshAccessToken` atomic check-and-insert** — new `ropts.checkAndInsert(token, expireAtMs)` callback contract replaces the previous `ropts.seen(token)` check-then-act race. Two concurrent refresh requests on the same event-loop tick could both see `seen === false` and both POST to the token endpoint, neither flagging the replay; the new contract requires an atomic test-and-set (Redis SETNX, DB INSERT ON CONFLICT) and is the OAuth 2.1 §6.1 / RFC 9700 §4.13 one-time-use defense surfacing the actual race window. Legacy `seen` callback continues to work for backwards-compat with operator code; the docstring documents the race + recommends migration to `checkAndInsert`. **`b.auth.oid4vci` constant-time compares** — pre-auth `tx_code` hash compare (was `!==` on sha3 hex) and proof-JWT `c_nonce` compare (was `!==` on attacker-supplied wallet payload) both route through `b.crypto.timingSafeEqual`. **`b.auth.oid4vp` per-presentation `vct` enforcement** — DCQL filters with 2+ `vct_values` entries previously bypassed vct validation entirely (the framework only set `expectedVct` when the filter pinned to a single value). Verifier now validates the presented vct against the DCQL filter list manually when length > 1; refuses with `vp_token['<id>'][<n>] vct '<presented>' is not in DCQL vct_values [...]` on over-disclosure. **`b.auth.ciba` slow_down honoring** — CIBA §11.3 requires the client to increase its polling interval by at least 5s on every `slow_down` response. Pre-v0.9.3 the framework client never bumped, leaving operators to do their own interval bookkeeping. Now `pollToken()` tracks per-`authReqId` interval state internally (Map keyed by authReqId, seeded from `startAuthentication`'s response, cleared on token issuance), bumps by `max(5s, IdP-suggested interval) <= MAX_INTERVAL_SEC` on every slow_down, and attaches the next-suggested interval to the thrown `auth-ciba/slow_down` error as `err.nextIntervalSec` so operators read a spec-correct back-off without manual bookkeeping. **`b.auth.ciba` notification-token entropy** — `clientNotificationToken` now refuses < 32 chars per CIBA §7.1.2's opaque-hard-to-guess requirement. Pre-v0.9.3 a 4-char token passed. **`b.auth.ciba.parseNotification` constant-time compare** — bearer-token hash compare migrated from `!==` to `b.crypto.timingSafeEqual` (both sides are fixed-width sha3-512 hex strings; defense-in-depth even though equal-length JS string compare is already widely understood as constant-time on V8).
|
|
12
14
|
- v0.9.2 (2026-05-11) — **Audit hardening slice 2: WebAuthn + FIDO MDS3 + NIST AAL**. Continues the 2026-05-11 auth surface audit follow-through. **`b.auth.passkey.verifyAuthentication` counter-regression bypass fix** — pre-v0.9.2 the wrapper coerced `opts.credential.counter || 0`, silently zeroing an `undefined` / `null` / `NaN` counter and defeating CTAP 2.1 clone-detection on credentials whose stored counter was > 0. An operator deserializing the credential from a column that lost the counter would unknowingly accept a cloned authenticator. The wrapper now refuses `undefined` / `null` (operators MUST persist whatever the vendor returned at registration; first-time-stored credentials carry counter:0 explicitly) and rejects any non-integer / non-finite / negative value with `auth-passkey/bad-counter`. **`b.auth.passkey` multi-origin support** — `expectedOrigin` now accepts `string` OR `string[]` on both `verifyRegistration` and `verifyAuthentication`. Pre-v0.9.2 the wrapper enforced a single string only, blocking multi-origin deployments (web + admin-subdomain) from sharing one verifier; SimpleWebAuthn natively supports arrays. **`b.auth.passkey` prototype-pollution fix** — `ALLOWED_MEDIATION` lookup changed from `{...}[opts.mediation]` to `hasOwnProperty.call(ALLOWED_MEDIATION, opts.mediation)` with a null-prototype map. Pre-v0.9.2 a caller passing `mediation: "__proto__"` / `"constructor"` truthy-matched an inherited Object.prototype property and slipped past the allowlist into `generateAuthenticationOptions`. **`b.auth.aal.fromMethods` UV requirement for AAL3** — per NIST SP 800-63-4 §5.1.7, WebAuthn / passkey satisfies AAL3 (MF-CRYPT) only when user verification was performed on the assertion. Pre-v0.9.2 `fromMethods({ webauthn: true })` returned `AAL3` unconditionally; operators using `userVerification: "preferred"` whose authenticator skipped UV landed in AAL3 despite not satisfying the spec's MF requirement. The caller now passes `uv: true` (sourced from the vendor's authData UV bit) to claim AAL3 with webauthn alone; without `uv`, webauthn alone caps at AAL2 (SF-CRYPT). Combination paths (`webauthn + password` / `webauthn + pin`) reach AAL3 regardless of UV (the memorized secret provides the second factor independently). **`b.auth.fidoMds3.verifyAuthenticator` fail-closed default + new opts** — pre-v0.9.2 unknown AAGUIDs returned `{ ok: true, reason: "aaguid-not-in-blob" }`, silently trusting any authenticator the metadata service hadn't yet listed (rogue / pre-certification / fake hardware). Now fails closed by default; operators wanting the legacy fail-open behavior (test fixtures, pre-certification pilot rollouts) pass `opts.allowUnknownAaguid: true` explicitly. **`b.auth.fidoMds3.parseBlob` stale-BLOB refusal** — refuses BLOB payloads whose `nextUpdate` is already in the past (FIDO MDS3 §3.1.7). Pre-v0.9.2 the staleness was floored to `MIN_CACHE_TTL_MS` but the BLOB was still served, letting an attacker pin operators to a revoked-authenticator-list-frozen-at-X by serving an ancient signed-but-expired BLOB. **`b.auth.fidoMds3.REFUSE_STATUS`** — added `ATTESTATION_KEY_COMPROMISE` per FIDO MDS3 §3.1.4. Pre-v0.9.2 this status was silently accepted; manufacturer batch-signing-key compromise affects every credential attested under that key.
|
|
13
15
|
- v0.9.1 (2026-05-11) — **Audit hardening, slice 1 of N: federation auth + OAuth ID-token verifier**. Audit run after v0.9.0 across OAuth / SAML / OIDC federation / SD-JWT VC / WebAuthn / FIDO MDS3 / constant-time-compare surfaces; this slice closes the highest-severity SAML + OIDC + SD-JWT + OAuth findings. **SAML SP** (`b.auth.saml.sp.create({...}).buildAuthnRequest` + `.metadata`): every operator-supplied URL / ID interpolated into the emitted XML now routes through `b.xmlC14n.escapeAttrValue` / `escapeText`. Pre-v0.9.1 a `"` or `<` in `idpSsoUrl` / `assertionConsumerServiceUrl` / `entityId` / `nameIdFormat` broke out of attribute / element context and produced unsigned-XML breakout into the IdP redirect. Newly exported `b.xmlC14n.escapeAttrValue(s)` and `b.xmlC14n.escapeText(s)` — RFC 3741 §1.3.x compliant; available for any operator emitting XML alongside the framework's own SAML / canonicalization paths. **SAML SP `verifyResponse`**: digest compare on `Reference DigestValue` and `SubjectConfirmation InResponseTo` now use `b.crypto.timingSafeEqual` instead of `Buffer.compare` / `!==`. **SAML XSW defense**: refuses Response payloads that carry duplicate `<Status>`, `<StatusCode>`, `<Assertion>`, `<Subject>`, or `<NameID>` children — XML signature wrapping attacks ferry an unsigned sibling next to a signed element and exploit first-match parsers; the verifier now asserts single-child cardinality on every security-critical element via `_findAllChildren(...).length === 1`. **OIDC federation** (`b.auth.openidFederation.verifyEntityStatement`): JWK key-type cross-check against the JWS `alg` header BEFORE `nodeCrypto.createPublicKey` — an attacker-controlled entity-config declaring `alg: "ES256"` while supplying an RSA JWK would previously load through Node's silent algorithm-vs-key coercion path. Now refuses with `auth-openid-federation/alg-kty-mismatch` for any `alg=ES*` not paired with `kty=EC`, `alg=PS*`/`RS*` not paired with `RSA`, or `alg=EdDSA` not paired with `OKP`. **`b.auth.openidFederation.buildTrustChain` error-masking**: trust-chain ascent previously swallowed every per-authority failure via `catch (_e) {}` and continued to the next `authority_hint`; signature-failure errors from one authority no longer mask, the chain now refuses on cryptographic refusal (`bad-jwk`, `alg-kty-mismatch`, `bad-signature`, `signature-failed`). Network / 404 / iss-sub-mismatch errors still continue to the next hint but are collected and surfaced in the `no-ascent` failure shape. **SD-JWT VC verify** (`b.auth.sdJwtVc.verify`): three correctness fixes. (1) `_sd_alg` default switched from `sha3-512` to `sha-256` per IETF draft §4.1.1 — prior default broke verification against spec-conformant issuers when the issuer omitted `_sd_alg`. (2) Disclosure-replay defense: every disclosure digest tracked in a Set; second occurrence of the same digest refuses with `auth-sd-jwt-vc/disclosure-replay`. (3) Claim-shadowing defense: holder-supplied disclosures whose name collides with an issuer-signed top-level claim (`iss`, `sub`, `aud`, `iat`, `nbf`, `exp`, `jti`, `vct`, `cnf`, `_sd`, `_sd_alg`, `status`) refuse with `auth-sd-jwt-vc/protected-claim-shadow` instead of silently overwriting the signed value. (4) KB-JWT `sd_hash` now uses the credential's declared `_sd_alg` (was hardcoded sha256, breaking against issuers using sha3-512); `sd_hash` compare routed through `b.crypto.timingSafeEqual`. **OAuth `verifyIdToken`** (`b.auth.oauth.verifyIdToken`): three hardening fixes that bring the verifier to parity with the framework's other JWS verifiers. (1) `nodeCrypto.verify` wrapped in try/catch — previously panicked on key/sig shape mismatch (e.g. ES256 sig against an RS256 key returned by a buggy IdP with duplicate kids), bubbling a raw `Error` to the operator's handler instead of an `OAuthError`. (2) RFC 7515 §4.1.11 `crit` header refusal — every sibling verifier (`b.auth.jwt`, `b.auth.jwt.verifyExternal`, `b.auth.dpop`) refuses; verifyIdToken previously silently ignored, letting an attacker-controlled OP ship critical extensions the verifier doesn't understand. (3) `state` and `nonce` claim compares routed through `b.crypto.timingSafeEqual` — these are CSRF / replay tokens compared against attacker-controlled callback / payload data.
|
package/lib/auth/ciba.js
CHANGED
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
* alongside CIBA all share one set of audited credentials).
|
|
52
52
|
*/
|
|
53
53
|
|
|
54
|
+
var C = require("../constants");
|
|
54
55
|
var lazyRequire = require("../lazy-require");
|
|
55
56
|
var validateOpts = require("../validate-opts");
|
|
56
57
|
var safeJson = require("../safe-json");
|
|
@@ -381,7 +382,7 @@ function create(opts) {
|
|
|
381
382
|
// Seed the per-authReqId interval tracker so pollToken's
|
|
382
383
|
// slow_down handler bumps from the IdP-supplied starting point
|
|
383
384
|
// (CIBA §11.3 minimum-5s bump on every slow_down response).
|
|
384
|
-
_registerInitialInterval(rv.auth_req_id, interval);
|
|
385
|
+
_registerInitialInterval(rv.auth_req_id, interval, expiresIn);
|
|
385
386
|
|
|
386
387
|
_emitAudit("start", "success", {
|
|
387
388
|
authReqIdHash: sha3Hash("auth-ciba:" + rv.auth_req_id),
|
|
@@ -418,10 +419,37 @@ function create(opts) {
|
|
|
418
419
|
// `slow_down` response. The framework client now maintains this
|
|
419
420
|
// state internally so operators reading `err.nextIntervalSec` get
|
|
420
421
|
// a spec-correct back-off without rolling their own counter.
|
|
421
|
-
|
|
422
|
+
// Map values are `{ interval, expireAtMs }`; entries TTL out at
|
|
423
|
+
// the per-authReqId expiry (startAuthentication's expiresIn). A
|
|
424
|
+
// periodic sweep + on-touch lazy purge prevent unbounded growth
|
|
425
|
+
// when authentication requests are denied / expire / never
|
|
426
|
+
// pollToken'd (e.g. ping/push delivery modes where the IdP
|
|
427
|
+
// notifies the RP and pollToken is never called).
|
|
428
|
+
var _intervalState = new Map();
|
|
422
429
|
|
|
423
|
-
function
|
|
424
|
-
_intervalState.
|
|
430
|
+
function _purgeExpiredIntervals(nowMs) {
|
|
431
|
+
var iter = _intervalState.entries();
|
|
432
|
+
var step = iter.next();
|
|
433
|
+
while (!step.done) {
|
|
434
|
+
var pair = step.value;
|
|
435
|
+
if (pair[1].expireAtMs <= nowMs) _intervalState.delete(pair[0]);
|
|
436
|
+
step = iter.next();
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function _registerInitialInterval(authReqId, intervalSec, expiresInSec) {
|
|
441
|
+
var nowMs = Date.now();
|
|
442
|
+
// Opportunistic sweep at every register so the cleanup runs
|
|
443
|
+
// on the same code path as growth — no separate timer.
|
|
444
|
+
_purgeExpiredIntervals(nowMs);
|
|
445
|
+
_intervalState.set(authReqId, {
|
|
446
|
+
interval: intervalSec,
|
|
447
|
+
// expireAtMs derived from the IdP's expires_in (the lifetime
|
|
448
|
+
// of the auth-req-id itself). Once the auth-req-id expires
|
|
449
|
+
// the entry can be purged regardless of whether pollToken
|
|
450
|
+
// succeeded.
|
|
451
|
+
expireAtMs: nowMs + C.TIME.seconds(expiresInSec),
|
|
452
|
+
});
|
|
425
453
|
}
|
|
426
454
|
|
|
427
455
|
async function pollToken(popts) {
|
|
@@ -458,7 +486,8 @@ function create(opts) {
|
|
|
458
486
|
// honor that when >= current + 5, otherwise enforce the
|
|
459
487
|
// minimum 5s bump.
|
|
460
488
|
if (err && err.code === "auth-ciba/slow_down") {
|
|
461
|
-
var
|
|
489
|
+
var entry = _intervalState.get(popts.authReqId);
|
|
490
|
+
var current = entry ? entry.interval : DEFAULT_INTERVAL_SEC;
|
|
462
491
|
var idpSuggested = err.cibaError && typeof err.cibaError.interval === "number"
|
|
463
492
|
? err.cibaError.interval : null;
|
|
464
493
|
var next = current + 5; // allow:raw-time-literal — §11.3 mandates +5s minimum
|
|
@@ -466,8 +495,20 @@ function create(opts) {
|
|
|
466
495
|
next = idpSuggested;
|
|
467
496
|
}
|
|
468
497
|
if (next > MAX_INTERVAL_SEC) next = MAX_INTERVAL_SEC;
|
|
469
|
-
|
|
498
|
+
if (entry) {
|
|
499
|
+
_intervalState.set(popts.authReqId, { interval: next, expireAtMs: entry.expireAtMs });
|
|
500
|
+
}
|
|
470
501
|
err.nextIntervalSec = next;
|
|
502
|
+
} else if (err && (
|
|
503
|
+
err.code === "auth-ciba/expired_token" ||
|
|
504
|
+
err.code === "auth-ciba/access_denied" ||
|
|
505
|
+
err.code === "auth-ciba/invalid_grant" ||
|
|
506
|
+
err.code === "auth-ciba/transaction_failed")) {
|
|
507
|
+
// Terminal CIBA errors (RFC §13) — the auth_req_id is now
|
|
508
|
+
// dead. Clear the per-authReqId interval entry so it
|
|
509
|
+
// doesn't leak when the operator stops polling. (Reported
|
|
510
|
+
// 2026-05-12.)
|
|
511
|
+
_intervalState.delete(popts.authReqId);
|
|
471
512
|
}
|
|
472
513
|
throw err;
|
|
473
514
|
}
|
package/lib/auth/jwt-external.js
CHANGED
|
@@ -187,7 +187,7 @@ async function _fetchJwks(uri, cacheMs) {
|
|
|
187
187
|
}, cacheMs || DEFAULT_JWKS_CACHE_MS);
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
-
function _selectKey(keys, header) {
|
|
190
|
+
function _selectKey(keys, header, vopts) {
|
|
191
191
|
if (!Array.isArray(keys) || keys.length === 0) {
|
|
192
192
|
throw new AuthError("auth-jwt-external/no-jwks-keys",
|
|
193
193
|
"JWKS source has no keys");
|
|
@@ -199,9 +199,18 @@ function _selectKey(keys, header) {
|
|
|
199
199
|
throw new AuthError("auth-jwt-external/no-matching-kid",
|
|
200
200
|
"no JWKS key matches header.kid='" + header.kid + "'");
|
|
201
201
|
}
|
|
202
|
-
|
|
202
|
+
// Refuse kid-less tokens by default (audit 2026-05-11). JWKS
|
|
203
|
+
// rotation creates a window where the rotated-out key is still
|
|
204
|
+
// cached but the rotated-in key is already published; an
|
|
205
|
+
// attacker shipping a kid-less token gets the lone-key path
|
|
206
|
+
// during that window. Modern IdPs always emit kid. Operators
|
|
207
|
+
// with non-conforming issuers opt in via vopts.allowKidlessJwks
|
|
208
|
+
// = true (logged via the caller's audit hook).
|
|
209
|
+
if (keys.length === 1 && vopts && vopts.allowKidlessJwks === true) return keys[0];
|
|
203
210
|
throw new AuthError("auth-jwt-external/kid-required",
|
|
204
|
-
"JWKS has " + keys.length + "
|
|
211
|
+
"JWKS has " + keys.length + " key(s) but token header has no kid — " +
|
|
212
|
+
"framework refuses kid-less tokens to defend against JWKS-rotation " +
|
|
213
|
+
"key-pick attacks (pass vopts.allowKidlessJwks: true to opt out)");
|
|
205
214
|
}
|
|
206
215
|
|
|
207
216
|
// ---- public surface ----
|
|
@@ -218,6 +227,10 @@ async function verifyExternal(token, opts) {
|
|
|
218
227
|
validateOpts(opts, [
|
|
219
228
|
"algorithms", "jwks", "jwksUri", "jwksCacheMs", "keyResolver",
|
|
220
229
|
"audience", "issuer", "subject", "clockSkewMs",
|
|
230
|
+
// v0.9.4 — opt-out for the kid-less-token JWKS-of-one refusal
|
|
231
|
+
// (default refuses; non-conforming IdPs that emit kid-less tokens
|
|
232
|
+
// set this true). Audit 2026-05-11.
|
|
233
|
+
"allowKidlessJwks",
|
|
221
234
|
], "auth.jwt.verifyExternal");
|
|
222
235
|
|
|
223
236
|
if (!Array.isArray(opts.algorithms) || opts.algorithms.length === 0) {
|
|
@@ -304,7 +317,7 @@ async function verifyExternal(token, opts) {
|
|
|
304
317
|
} else {
|
|
305
318
|
var keys = opts.jwks ? opts.jwks
|
|
306
319
|
: await _fetchJwks(opts.jwksUri, opts.jwksCacheMs);
|
|
307
|
-
var jwk = _selectKey(keys, header);
|
|
320
|
+
var jwk = _selectKey(keys, header, opts);
|
|
308
321
|
key = _jwkToKey(jwk);
|
|
309
322
|
}
|
|
310
323
|
|
package/lib/auth/oauth.js
CHANGED
|
@@ -334,6 +334,14 @@ function create(opts) {
|
|
|
334
334
|
var allowInternal = opts.allowInternal != null ? opts.allowInternal : null; // localhost dev opt-in (SSRF gate)
|
|
335
335
|
var httpClientOpts = opts.httpClient || {};
|
|
336
336
|
var responseMode = opts.responseMode || null;
|
|
337
|
+
// v0.9.5 — client-level opt-out for the kid-less JWKS-of-one
|
|
338
|
+
// refusal added in v0.9.4. Surfaced at the create() level (not
|
|
339
|
+
// per-verifyIdToken-call) so it threads through every code path
|
|
340
|
+
// that lands on verifyIdToken — _normalizeTokens for exchangeCode
|
|
341
|
+
// / pollDeviceCode / exchangeToken / refreshAccessToken, JARM
|
|
342
|
+
// wrapper, and the public verifyIdToken entry point. Operators
|
|
343
|
+
// with non-conforming IdPs set this once at client construction.
|
|
344
|
+
var allowKidlessJwks = opts.allowKidlessJwks === true;
|
|
337
345
|
|
|
338
346
|
if (!clientId) {
|
|
339
347
|
throw new OAuthError("auth-oauth/no-client-id", "create: opts.clientId is required");
|
|
@@ -566,23 +574,31 @@ function create(opts) {
|
|
|
566
574
|
// check ran via `ropts.seen(token)` which was a check-then-act
|
|
567
575
|
// race: two concurrent refresh requests landed on the same
|
|
568
576
|
// event-loop tick could both see `seen === false` and both POST
|
|
569
|
-
// to the token endpoint, neither flagging the replay. The
|
|
570
|
-
// contract
|
|
571
|
-
//
|
|
572
|
-
//
|
|
573
|
-
//
|
|
574
|
-
//
|
|
577
|
+
// to the token endpoint, neither flagging the replay. The
|
|
578
|
+
// framework-wide checkAndInsert contract (lib/nonce-store.js,
|
|
579
|
+
// lib/auth/jwt.js) is: returns `true` when the value was UNSEEN
|
|
580
|
+
// and is now recorded (first sighting); returns `false` when
|
|
581
|
+
// already present (replay). The legacy `seen` callback returned
|
|
582
|
+
// the opposite (true means seen-already); both surfaces are
|
|
583
|
+
// supported but normalize to a single `alreadySeen` boolean
|
|
584
|
+
// below.
|
|
575
585
|
var alreadySeen = false;
|
|
576
586
|
if (typeof ropts.checkAndInsert === "function") {
|
|
577
587
|
var nowMs = Date.now();
|
|
578
588
|
// 24h max refresh-token TTL — operators with shorter TTLs
|
|
579
589
|
// should configure their store's own expiry policy.
|
|
580
590
|
var expireAtMs = nowMs + C.TIME.hours(24);
|
|
581
|
-
|
|
591
|
+
var inserted;
|
|
592
|
+
try { inserted = await ropts.checkAndInsert(refreshToken, expireAtMs); }
|
|
582
593
|
catch (e) {
|
|
583
594
|
throw new OAuthError("auth-oauth/seen-callback-failed",
|
|
584
595
|
"refreshAccessToken: checkAndInsert() callback threw: " + ((e && e.message) || String(e)));
|
|
585
596
|
}
|
|
597
|
+
// Spec contract: inserted===true → first sighting (OK);
|
|
598
|
+
// inserted===false → replay. v0.9.3 had this inverted, which
|
|
599
|
+
// broke every first refresh attempt for operators reusing an
|
|
600
|
+
// existing b.nonceStore-style backend. (Reported 2026-05-12.)
|
|
601
|
+
alreadySeen = inserted === false;
|
|
586
602
|
} else if (typeof ropts.seen === "function") {
|
|
587
603
|
// Legacy non-atomic path. Documented as a check-then-act race;
|
|
588
604
|
// operators sharing a single-writer store (Redis SETNX, DB
|
|
@@ -898,7 +914,14 @@ function create(opts) {
|
|
|
898
914
|
expiresIn: raw.expires_in || null,
|
|
899
915
|
refreshToken: raw.refresh_token || null,
|
|
900
916
|
idToken: raw.id_token || null,
|
|
901
|
-
|
|
917
|
+
// RFC 6749 §3.3 — scope is space-separated, ONLY U+0020.
|
|
918
|
+
// `\s+` previously matched U+0085 NEL, U+00A0 NBSP, etc., so a
|
|
919
|
+
// hostile AS returning `scope: "admin<NEL>read"` would
|
|
920
|
+
// surface as `["admin", "read"]` and the operator's scope
|
|
921
|
+
// allowlist saw two distinct scopes. Spec-strict split on
|
|
922
|
+
// single-space + reject scope tokens that contain non-token
|
|
923
|
+
// chars. (Audit 2026-05-11.)
|
|
924
|
+
scope: raw.scope ? raw.scope.split(" ").filter(function (s) { return s.length > 0; }) : scope.slice(),
|
|
902
925
|
raw: raw,
|
|
903
926
|
};
|
|
904
927
|
if (tokens.idToken && isOidc) {
|
|
@@ -968,12 +991,39 @@ function create(opts) {
|
|
|
968
991
|
for (var i = 0; i < keys.length; i++) {
|
|
969
992
|
if (keys[i].kid === header.kid) { match = keys[i]; break; }
|
|
970
993
|
}
|
|
971
|
-
} else if (keys.length === 1) {
|
|
972
|
-
match = keys[0];
|
|
973
994
|
}
|
|
995
|
+
// Pre-v0.9.4 fell back to keys[0] when the token carried NO kid
|
|
996
|
+
// and the JWKS had exactly one key. This is a latent vector
|
|
997
|
+
// during JWKS rotation: an attacker who can ship a kid-less
|
|
998
|
+
// token gets the lone key during the window the rotated-out
|
|
999
|
+
// key was still cached at the IdP but the rotated-in key is
|
|
1000
|
+
// already published. Refuse kid-less tokens unconditionally —
|
|
1001
|
+
// every modern IdP includes kid; absent kid is a spec smell.
|
|
1002
|
+
// (Audit 2026-05-11.) Operators with non-conforming IdPs that
|
|
1003
|
+
// genuinely emit kid-less tokens can opt out via
|
|
1004
|
+
// vopts.allowKidlessJwks = true with a logged warning.
|
|
974
1005
|
if (!match) {
|
|
975
|
-
|
|
976
|
-
|
|
1006
|
+
// Operator opt-out reads from EITHER the per-call vopts OR the
|
|
1007
|
+
// client-level config — `_normalizeTokens` calls verifyIdToken
|
|
1008
|
+
// with a reduced vopts ({ nonce, skipNonceCheck }), so a
|
|
1009
|
+
// per-call opt would not reach the standard exchangeCode /
|
|
1010
|
+
// pollDeviceCode / exchangeToken / refreshAccessToken flows.
|
|
1011
|
+
// The client-level `create({ allowKidlessJwks: true })` fills
|
|
1012
|
+
// that gap. (v0.9.5 follow-up to the v0.9.4 audit fix.)
|
|
1013
|
+
var allowKidless = vopts.allowKidlessJwks === true || allowKidlessJwks;
|
|
1014
|
+
if (!header.kid && keys.length === 1 && allowKidless) {
|
|
1015
|
+
match = keys[0];
|
|
1016
|
+
} else {
|
|
1017
|
+
throw new OAuthError("auth-oauth/no-matching-key",
|
|
1018
|
+
header.kid
|
|
1019
|
+
? "no JWKS key matches header.kid='" + header.kid + "'"
|
|
1020
|
+
: "ID token has no kid header; framework refuses kid-less " +
|
|
1021
|
+
"tokens to defend against JWKS-rotation key-pick attacks " +
|
|
1022
|
+
"(pass `allowKidlessJwks: true` to b.auth.oauth.create() — " +
|
|
1023
|
+
"client-level — if your IdP genuinely emits kid-less tokens; " +
|
|
1024
|
+
"or vopts.allowKidlessJwks: true on a single verifyIdToken " +
|
|
1025
|
+
"call)");
|
|
1026
|
+
}
|
|
977
1027
|
}
|
|
978
1028
|
var keyObject = _jwkToKey(match);
|
|
979
1029
|
var params = _verifyParamsForAlg(header.alg);
|
package/lib/middleware/dpop.js
CHANGED
|
@@ -102,12 +102,33 @@ function _nonceManager(rotateSec) {
|
|
|
102
102
|
};
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
function _reconstructHtu(req) {
|
|
105
|
+
function _reconstructHtu(req, mopts) {
|
|
106
106
|
// The proof's htu is the request URI WITHOUT query/fragment. Behind
|
|
107
107
|
// a reverse proxy the operator may need to override via opts.htu /
|
|
108
|
-
// opts.getHtu
|
|
109
|
-
|
|
110
|
-
|
|
108
|
+
// opts.getHtu. X-Forwarded-* headers are ATTACKER-CONTROLLED when
|
|
109
|
+
// the origin is reachable directly; an attacker who can hit the
|
|
110
|
+
// origin while spoofing X-Forwarded-Proto: https can trick this
|
|
111
|
+
// function into building an `https` htu that the DPoP proof was
|
|
112
|
+
// signed for — when the origin is actually serving HTTP. RFC 9449
|
|
113
|
+
// §4.3 says htu MUST be the absolute URL the request was sent to.
|
|
114
|
+
//
|
|
115
|
+
// Default: ignore X-Forwarded-* and derive proto/host from the
|
|
116
|
+
// socket. Operators with a confirmed-trusted front proxy opt in
|
|
117
|
+
// via opts.trustForwardedHeaders: true. (Audit 2026-05-11.)
|
|
118
|
+
mopts = mopts || {};
|
|
119
|
+
var trustForwarded = mopts.trustForwardedHeaders === true;
|
|
120
|
+
var proto;
|
|
121
|
+
if (trustForwarded && req.headers["x-forwarded-proto"]) {
|
|
122
|
+
proto = String(req.headers["x-forwarded-proto"]).split(",")[0].trim();
|
|
123
|
+
} else {
|
|
124
|
+
proto = req.socket && req.socket.encrypted ? "https" : "http";
|
|
125
|
+
}
|
|
126
|
+
var host;
|
|
127
|
+
if (trustForwarded && req.headers["x-forwarded-host"]) {
|
|
128
|
+
host = String(req.headers["x-forwarded-host"]).split(",")[0].trim();
|
|
129
|
+
} else {
|
|
130
|
+
host = req.headers.host;
|
|
131
|
+
}
|
|
111
132
|
if (!host) return null;
|
|
112
133
|
var path = req.url || "/";
|
|
113
134
|
var qIdx = path.indexOf("?");
|
|
@@ -163,6 +184,10 @@ function create(opts) {
|
|
|
163
184
|
"replayStore", "algorithms", "iatWindowSec",
|
|
164
185
|
"getAccessToken", "getNonce", "getHtu", "audit",
|
|
165
186
|
"nonceStore", "nonceWindowSec", "nonceRotateSec", "requireNonce",
|
|
187
|
+
// v0.9.4 — opt-in trust gate for X-Forwarded-Proto/Host when
|
|
188
|
+
// reconstructing htu. Default off (audit 2026-05-11); operators
|
|
189
|
+
// with a confirmed-trusted front proxy set this to `true`.
|
|
190
|
+
"trustForwardedHeaders",
|
|
166
191
|
], "middleware.dpop");
|
|
167
192
|
|
|
168
193
|
var auditOn = opts.audit !== false;
|
|
@@ -217,7 +242,7 @@ function create(opts) {
|
|
|
217
242
|
"multiple DPoP headers are not allowed");
|
|
218
243
|
}
|
|
219
244
|
|
|
220
|
-
var htu = (typeof opts.getHtu === "function" ? opts.getHtu(req) : _reconstructHtu(req));
|
|
245
|
+
var htu = (typeof opts.getHtu === "function" ? opts.getHtu(req) : _reconstructHtu(req, opts));
|
|
221
246
|
if (!htu) {
|
|
222
247
|
return _writeUnauthorized(res, "invalid_dpop_proof", "could not reconstruct htu");
|
|
223
248
|
}
|
package/lib/network-tls.js
CHANGED
|
@@ -1103,7 +1103,14 @@ function evaluateOcspResponse(ocspDer, opts) {
|
|
|
1103
1103
|
return { ok: false, status: parsed.status, signatureValid: true,
|
|
1104
1104
|
errors: ["OCSP response missing nonce extension (expected for replay defense)"] };
|
|
1105
1105
|
}
|
|
1106
|
-
|
|
1106
|
+
// Constant-time compare — module-wide consistency with the
|
|
1107
|
+
// Merkle-root / NTS-cookie / cert-fingerprint paths that already
|
|
1108
|
+
// use timingSafeEqual. Buffer.equals is constant-time on equal-
|
|
1109
|
+
// length inputs but fast-paths on length mismatch; not security-
|
|
1110
|
+
// critical here (the OCSP response is CA-signed and signature
|
|
1111
|
+
// already verified) but matches the project discipline.
|
|
1112
|
+
// (Audit 2026-05-11.)
|
|
1113
|
+
if (!blamejsCrypto.timingSafeEqual(parsed.basic.nonce, opts.expectedNonce)) {
|
|
1107
1114
|
return { ok: false, status: parsed.status, signatureValid: true,
|
|
1108
1115
|
errors: ["OCSP nonce mismatch — possible replay or wrong responder"] };
|
|
1109
1116
|
}
|
package/package.json
CHANGED
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.6",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:851d345b-6891-4a75-bac3-11ec1478b08d",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
8
|
+
"timestamp": "2026-05-12T03:03:37.327Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/core@0.9.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.9.5",
|
|
23
23
|
"type": "library",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.9.
|
|
25
|
+
"version": "0.9.5",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "The Node framework that owns its stack.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/core@0.9.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.9.5",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/core@0.9.
|
|
57
|
+
"ref": "@blamejs/core@0.9.5",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|