@blamejs/core 0.7.84 → 0.7.86

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 CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.7.x
10
10
 
11
+ - **0.7.86** (2026-05-06) — `b.middleware.csrfProtect({ requireJsonContentType: true })` — strict-fetch mode for JSON-only API surfaces. State-changing requests (POST/PUT/PATCH/DELETE) without `Content-Type: application/json` are refused before the token check with `CSRF: state-changing requests require Content-Type: application/json.` and audit emission `csrf.denied` with `reason: "non-JSON content-type: ..."`. The browser's form-encoded POST shape is the canonical CSRF vector — a malicious page can `<form action="/transfer" method=POST>` a victim into a state-changing request without a preflight. An `application/json` body forces a CORS preflight (the browser refuses to skip it for non-simple Content-Type values), so an attacker without an operator-allowlisted CORS origin can't reach the route at all. Default `false` — operators with HTML form submissions on the same routes (mixed SPA + classic form pages) keep current behavior; pure-fetch API operators opt in.
12
+
13
+ - **0.7.85** (2026-05-06) — `b.auth.statusList` — OAuth Token Status List (draft-ietf-oauth-status-list-20). The canonical credential-revocation mechanism for SD-JWT VC and OpenID for Verifiable Credentials. An issuer publishes a JWT-wrapped bitstring at a URL; relying parties fetch + check the bit at index N to determine if the credential whose `status_list` claim points at that URL+index is valid / invalid / suspended / application-specific. **`b.auth.statusList.create({ size, bits?, fill? })`** allocates a bit-packed buffer (`bits` ∈ {1, 2, 4, 8} per draft §6.1.1, default 1). The returned object exposes `.set(idx, status)` / `.get(idx)` / `.snapshot()` / `.toJwt({ issuer, subject, privateKey, algorithm, expiresInSec?, ... })`. **`b.auth.statusList.fromJwt(token, { publicKey | keyResolver, algorithms?, expectedIssuer?, ... })`** verifies the JWT through `b.auth.jwt.verify` and returns `{ list, claims }` — the list exposes `.get(idx)` to check status of an individual credential without decompressing into a separate Buffer. The bitstring is zlib-deflated (RFC 1951 raw deflate per draft §6.1.4) before base64url encoding so a million-entry list collapses to ~125 KB on the wire when most bits are zero. Caps the compressed payload at 1 MiB; operators publishing larger lists shard. Status constants exported as `b.auth.statusList.STATUS_{VALID,INVALID,SUSPENDED,APPLICATION_SPECIFIC}`. Foundational for the v0.7.58 EU AI Act + eIDAS 2.0 wallet slice.
14
+
11
15
  - **0.7.84** (2026-05-06) — `b.crypto.sri(content, { algorithm? })` — Subresource Integrity hash builder per W3C SRI 1.0. Operators emit `<script integrity="sha384-...">` and `<link integrity="sha384-...">` to defend against CDN compromise + ISP MITM injection — the browser refuses to load a resource whose actual hash diverges from the integrity attribute. Default algorithm is `sha384` (W3C §3.2 — collision margin without sha512's 64-byte overhead); `sha256` and `sha512` also accepted, anything else refused. Accepts `Buffer` / `Uint8Array` / `string` / array of those — array inputs emit multiple space-separated integrity tokens per W3C §3.3 multi-integrity (browser picks the strongest it recognizes). Returns the standard `sha###-<base64>` format ready to paste into the `integrity=""` attribute.
12
16
 
13
17
  - **0.7.83** (2026-05-06) — `b.auth.oauth.endSessionUrl()` (OpenID Connect RP-Initiated Logout) + `b.auth.oauth.pushAuthorizationRequest()` (RFC 9126 PAR). **`endSessionUrl({ idTokenHint?, postLogoutRedirectUri?, state?, logoutHint?, uiLocales?, clientId?, extraParams? })`** builds the URL the operator's `/logout` route redirects the user-agent to so the IdP terminates the session and bounces back to the operator's app. The IdP's `end_session_endpoint` is read from the OIDC discovery document or operator-supplied at `create({ endSessionEndpoint })`. **`pushAuthorizationRequest({ state?, nonce?, prompt?, loginHint?, maxAge?, extraParams? })`** POSTs the authorization-request parameters directly to the IdP's PAR endpoint (mTLS or client-secret authenticated) and returns `{ url, state, nonce, verifier, challenge, requestUri, expiresIn }` — the browser-side redirect URL is `<authorizationEndpoint>?client_id=...&request_uri=...`. Defends against authorization-request parameter tampering by an MITM at the user-agent + against URL-length overflow on long authorization requests (request_uri reference is short). The PAR endpoint is read from the discovery doc's `pushed_authorization_request_endpoint` or operator-supplied at `create({ pushedAuthorizationRequestEndpoint })`. Both helpers throw `auth-oauth/no-end-session-endpoint` / `auth-oauth/no-par-endpoint` when neither the discovery doc nor the operator opts supply the endpoint.
package/index.js CHANGED
@@ -139,6 +139,7 @@ var auth = {
139
139
  lockout: require("./lib/auth/lockout"),
140
140
  dpop: require("./lib/auth/dpop"),
141
141
  aal: require("./lib/auth/aal"),
142
+ statusList: require("./lib/auth/status-list"),
142
143
  };
143
144
  var template = require("./lib/template");
144
145
  var render = require("./lib/render");
@@ -0,0 +1,271 @@
1
+ "use strict";
2
+ /**
3
+ * OAuth Token Status List (draft-ietf-oauth-status-list-20).
4
+ *
5
+ * An issuer publishes a JWT-wrapped bitstring at a URL; relying
6
+ * parties fetch + check the bit at index N to determine if the
7
+ * credential whose `status_list` claim points at that URL + index
8
+ * has been revoked / suspended / is still valid. The format is the
9
+ * canonical replacement for the older "status list" mechanisms in
10
+ * SD-JWT VC and OpenID for Verifiable Credentials.
11
+ *
12
+ * Status values per draft §4.2:
13
+ * 0 = VALID
14
+ * 1 = INVALID
15
+ * 2 = SUSPENDED
16
+ * 3 = APPLICATION_SPECIFIC
17
+ * ... (1-bit / 2-bit / 4-bit / 8-bit `bits` size — operator picks)
18
+ *
19
+ * var list = b.auth.statusList.create({ size: 1024, bits: 1 });
20
+ * list.set(42, 1); // mark idx 42 INVALID
21
+ * var jwt = await list.toJwt({
22
+ * issuer: "https://issuer.example.com",
23
+ * subject: "https://issuer.example.com/status/list/1",
24
+ * privateKey: env("STATUS_LIST_PRIVATE_KEY_PEM"),
25
+ * algorithm: "ML-DSA-87", // matches b.auth.jwt's PQC default
26
+ * });
27
+ *
28
+ * // Receive side:
29
+ * var rv = await b.auth.statusList.fromJwt(jwt, { publicKey: pem });
30
+ * rv.list.get(42) // → 1 (INVALID)
31
+ *
32
+ * The JWT payload shape per draft §6.1:
33
+ * {
34
+ * iss: "<issuer>",
35
+ * sub: "<this-list-uri>",
36
+ * iat: <issued-at>,
37
+ * exp: <optional-expires>,
38
+ * ttl: <optional-cache-ttl>,
39
+ * status_list: { bits: 1|2|4|8, lst: "<base64url(zlib(bitstring))>" },
40
+ * }
41
+ *
42
+ * The bitstring is zlib-deflated (RFC 1951 raw deflate per draft
43
+ * §6.1.4) before base64url encoding so a million-entry list collapses
44
+ * to a few KB on the wire when most bits are zero.
45
+ */
46
+
47
+ var nodeCrypto = require("crypto");
48
+ var zlib = require("node:zlib");
49
+ var safeJson = require("../safe-json");
50
+ var validateOpts = require("../validate-opts");
51
+ var C = require("../constants");
52
+ var jwt = require("./jwt");
53
+ var { defineClass } = require("../framework-error");
54
+
55
+ var StatusListError = defineClass("StatusListError", { alwaysPermanent: true });
56
+
57
+ var SUPPORTED_BIT_SIZES = { 1: 1, 2: 1, 4: 1, 8: 1 }; // allow:raw-byte-literal — bit-size enum (1/2/4/8 bits per status), not bytes
58
+ var STATUS_VALID = 0;
59
+ var STATUS_INVALID = 1;
60
+ var STATUS_SUSPENDED = 2;
61
+ var STATUS_APPLICATION_SPECIFIC = 3;
62
+
63
+ // Cap the on-the-wire compressed payload at 1 MiB (a million 1-bit
64
+ // entries compress to ~125 KB when most are zero; 8 MiB on the wire
65
+ // is more than the spec's expected use). Operators publishing larger
66
+ // status lists should shard.
67
+ var MAX_LIST_BYTES = C.BYTES.mib(1);
68
+
69
+ function _b64url(buf) {
70
+ return buf.toString("base64").replace(/=+$/g, "").replace(/\+/g, "-").replace(/\//g, "_");
71
+ }
72
+
73
+ function _fromB64url(s) {
74
+ var padded = s.replace(/-/g, "+").replace(/_/g, "/");
75
+ while (padded.length % 4) padded += "="; // allow:raw-byte-literal — base64 quartet padding
76
+ return Buffer.from(padded, "base64");
77
+ }
78
+
79
+ function _validateBits(bits) {
80
+ if (!SUPPORTED_BIT_SIZES[bits]) {
81
+ throw new StatusListError("status-list/bad-bits",
82
+ "statusList: bits must be 1, 2, 4, or 8 (draft §6.1.1) — got " + bits);
83
+ }
84
+ }
85
+
86
+ function _validateStatus(status, bits) {
87
+ if (typeof status !== "number" || !isFinite(status) || status < 0 || (status >> 0) !== status) {
88
+ throw new StatusListError("status-list/bad-status",
89
+ "statusList: status must be a non-negative integer — got " + status);
90
+ }
91
+ var max = (1 << bits) - 1;
92
+ if (status > max) {
93
+ throw new StatusListError("status-list/bad-status",
94
+ "statusList: status " + status + " exceeds bits=" + bits + " ceiling " + max);
95
+ }
96
+ }
97
+
98
+ function create(opts) {
99
+ validateOpts.requireObject(opts, "statusList.create", StatusListError);
100
+ validateOpts(opts, ["size", "bits", "fill"], "statusList.create");
101
+ var size = opts.size;
102
+ if (typeof size !== "number" || !isFinite(size) || size <= 0 || (size >> 0) !== size) {
103
+ throw new StatusListError("status-list/bad-size",
104
+ "statusList.create: size must be a positive integer — got " + size);
105
+ }
106
+ var bits = opts.bits === undefined ? 1 : opts.bits;
107
+ _validateBits(bits);
108
+ // Allocate the bit-packed buffer up front. byteCount = ceil(size*bits/8).
109
+ var bitBytes = Math.ceil((size * bits) / 8); // allow:raw-byte-literal — bits-per-byte conversion
110
+ var bytes = Buffer.alloc(bitBytes);
111
+ if (opts.fill !== undefined && opts.fill !== 0) {
112
+ _validateStatus(opts.fill, bits);
113
+ for (var i = 0; i < size; i += 1) _setAt(bytes, bits, i, opts.fill);
114
+ }
115
+
116
+ function set(idx, status) {
117
+ if (typeof idx !== "number" || idx < 0 || idx >= size || (idx >> 0) !== idx) {
118
+ throw new StatusListError("status-list/bad-index",
119
+ "statusList.set: idx out of range — got " + idx + ", size=" + size);
120
+ }
121
+ _validateStatus(status, bits);
122
+ _setAt(bytes, bits, idx, status);
123
+ }
124
+
125
+ function get(idx) {
126
+ if (typeof idx !== "number" || idx < 0 || idx >= size || (idx >> 0) !== idx) {
127
+ throw new StatusListError("status-list/bad-index",
128
+ "statusList.get: idx out of range — got " + idx + ", size=" + size);
129
+ }
130
+ return _getAt(bytes, bits, idx);
131
+ }
132
+
133
+ function snapshot() {
134
+ return { size: size, bits: bits, bytes: Buffer.from(bytes) };
135
+ }
136
+
137
+ // ---- JWT issuance ----
138
+ // Returns the canonical JWT payload + signed compact form per
139
+ // draft §6.1. The signing key is operator-supplied; the framework
140
+ // wraps b.auth.jwt.sign with the status-list-specific claim shape.
141
+ async function toJwt(jwtOpts) {
142
+ validateOpts.requireObject(jwtOpts, "statusList.toJwt", StatusListError);
143
+ validateOpts(jwtOpts, [
144
+ "issuer", "subject", "privateKey", "algorithm",
145
+ "expiresInSec", "notBeforeSec", "now", "ttl",
146
+ ], "statusList.toJwt");
147
+ validateOpts.requireNonEmptyString(jwtOpts.issuer,
148
+ "statusList.toJwt: issuer", StatusListError, "status-list/bad-issuer");
149
+ validateOpts.requireNonEmptyString(jwtOpts.subject,
150
+ "statusList.toJwt: subject", StatusListError, "status-list/bad-subject");
151
+ var deflated = zlib.deflateRawSync(bytes);
152
+ if (deflated.length > MAX_LIST_BYTES) {
153
+ throw new StatusListError("status-list/too-large",
154
+ "statusList.toJwt: compressed list exceeds " + MAX_LIST_BYTES + " bytes — shard the list");
155
+ }
156
+ var lst = _b64url(deflated);
157
+ var claims = {
158
+ iss: jwtOpts.issuer,
159
+ sub: jwtOpts.subject,
160
+ status_list: { bits: bits, lst: lst },
161
+ };
162
+ if (typeof jwtOpts.ttl === "number") claims.ttl = jwtOpts.ttl;
163
+ return await jwt.sign(claims, {
164
+ privateKey: jwtOpts.privateKey,
165
+ algorithm: jwtOpts.algorithm,
166
+ typ: "statuslist+jwt",
167
+ expiresInSec: jwtOpts.expiresInSec,
168
+ notBeforeSec: jwtOpts.notBeforeSec,
169
+ now: jwtOpts.now,
170
+ });
171
+ }
172
+
173
+ return {
174
+ set: set,
175
+ get: get,
176
+ size: size,
177
+ bits: bits,
178
+ snapshot: snapshot,
179
+ toJwt: toJwt,
180
+ };
181
+ }
182
+
183
+ // ---- bit-packed helpers ----
184
+
185
+ function _setAt(bytes, bits, idx, status) {
186
+ if (bits === 8) { bytes[idx] = status & 0xff; return; } // allow:raw-byte-literal — byte mask
187
+ var bitOffset = idx * bits;
188
+ var byteIdx = Math.floor(bitOffset / 8); // allow:raw-byte-literal — bits-per-byte
189
+ var bitInByte = bitOffset % 8; // allow:raw-byte-literal — bits-per-byte
190
+ var mask = ((1 << bits) - 1) << bitInByte;
191
+ bytes[byteIdx] = (bytes[byteIdx] & ~mask) | ((status << bitInByte) & mask);
192
+ }
193
+
194
+ function _getAt(bytes, bits, idx) {
195
+ if (bits === 8) return bytes[idx]; // allow:raw-byte-literal — 8-bit fast path
196
+ var bitOffset = idx * bits;
197
+ var byteIdx = Math.floor(bitOffset / 8); // allow:raw-byte-literal — bits-per-byte
198
+ var bitInByte = bitOffset % 8; // allow:raw-byte-literal — bits-per-byte
199
+ var mask = (1 << bits) - 1;
200
+ return (bytes[byteIdx] >> bitInByte) & mask;
201
+ }
202
+
203
+ // ---- JWT verification ----
204
+
205
+ async function fromJwt(token, opts) {
206
+ validateOpts.requireObject(opts, "statusList.fromJwt", StatusListError);
207
+ if (typeof token !== "string" || token.length === 0) {
208
+ throw new StatusListError("status-list/bad-token",
209
+ "statusList.fromJwt: token must be a non-empty string");
210
+ }
211
+ // Verify the JWT signature using the framework's b.auth.jwt verifier.
212
+ // Allow operator-supplied algorithms (defaults to PQC list).
213
+ var claims = await jwt.verify(token, {
214
+ publicKey: opts.publicKey,
215
+ keyResolver: opts.keyResolver,
216
+ algorithms: opts.algorithms,
217
+ issuer: opts.expectedIssuer,
218
+ audience: opts.expectedAudience,
219
+ clockToleranceSec: opts.clockToleranceSec,
220
+ now: opts.now,
221
+ });
222
+ var sl = claims.status_list;
223
+ if (!sl || typeof sl !== "object" || typeof sl.lst !== "string") {
224
+ throw new StatusListError("status-list/bad-claims",
225
+ "statusList.fromJwt: payload missing status_list.lst (draft §6.1)");
226
+ }
227
+ var bits = sl.bits === undefined ? 1 : sl.bits;
228
+ _validateBits(bits);
229
+ var deflated;
230
+ try { deflated = _fromB64url(sl.lst); }
231
+ catch (e) {
232
+ throw new StatusListError("status-list/bad-base64",
233
+ "statusList.fromJwt: lst is not valid base64url: " + ((e && e.message) || String(e)));
234
+ }
235
+ if (deflated.length > MAX_LIST_BYTES) {
236
+ throw new StatusListError("status-list/too-large",
237
+ "statusList.fromJwt: compressed list exceeds " + MAX_LIST_BYTES + " bytes");
238
+ }
239
+ var inflated;
240
+ try { inflated = zlib.inflateRawSync(deflated, { maxOutputLength: MAX_LIST_BYTES * 8 }); } // allow:raw-byte-literal — 8x compression-ratio cap
241
+ catch (e) {
242
+ throw new StatusListError("status-list/inflate-failed",
243
+ "statusList.fromJwt: zlib inflate failed: " + ((e && e.message) || String(e)));
244
+ }
245
+ // Reconstruct the list object pointing at the inflated bytes.
246
+ var size = (inflated.length * 8) / bits; // allow:raw-byte-literal — bits-per-byte
247
+ return {
248
+ list: {
249
+ size: size,
250
+ bits: bits,
251
+ get: function (idx) { return _getAt(inflated, bits, idx); },
252
+ snapshot: function () { return { size: size, bits: bits, bytes: Buffer.from(inflated) }; },
253
+ },
254
+ claims: claims,
255
+ };
256
+ }
257
+
258
+ // Provide structured-error helpers so a tree-shake-friendly consumer
259
+ // can write switch(status) { case b.auth.statusList.STATUS_VALID: ... }.
260
+ void safeJson; // imported for symmetry; reserved for future helpers
261
+ void nodeCrypto;
262
+
263
+ module.exports = {
264
+ create: create,
265
+ fromJwt: fromJwt,
266
+ STATUS_VALID: STATUS_VALID,
267
+ STATUS_INVALID: STATUS_INVALID,
268
+ STATUS_SUSPENDED: STATUS_SUSPENDED,
269
+ STATUS_APPLICATION_SPECIFIC: STATUS_APPLICATION_SPECIFIC,
270
+ StatusListError: StatusListError,
271
+ };
@@ -228,7 +228,7 @@ function create(opts) {
228
228
 
229
229
  validateOpts(opts, [
230
230
  "cookie", "tokenLookup", "fieldName", "headerName", "methods", "audit",
231
- "trustProxy", "checkOrigin", "allowedOrigins",
231
+ "trustProxy", "checkOrigin", "allowedOrigins", "requireJsonContentType",
232
232
  ], "middleware.csrfProtect");
233
233
  var trustProxy = opts.trustProxy === true || typeof opts.trustProxy === "number"
234
234
  ? opts.trustProxy : false;
@@ -264,6 +264,19 @@ function create(opts) {
264
264
  var allowedOrigins = Array.isArray(opts.allowedOrigins)
265
265
  ? opts.allowedOrigins.slice() : null;
266
266
 
267
+ // requireJsonContentType — strict-fetch mode for JSON-only API
268
+ // surfaces. State-changing requests without `Content-Type:
269
+ // application/json` are refused before the token check. The browser's
270
+ // form-encoded POST shape is the canonical CSRF vector — a malicious
271
+ // page can <form action="/transfer" method=POST> a victim into a
272
+ // state-changing request without a preflight; an `application/json`
273
+ // body forces a CORS preflight (the browser refuses to skip it for
274
+ // non-simple Content-Type values), so an attacker without an
275
+ // operator-allowlisted CORS origin can't reach the route at all.
276
+ // Operators with HTML form submissions on the same routes (mixed
277
+ // SPA + classic form pages) leave this opt-out (default).
278
+ var requireJsonCt = opts.requireJsonContentType === true;
279
+
267
280
  // Cookie issuance config (only when opts.cookie is set).
268
281
  var cookieCfg = null;
269
282
  if (hasCookie) {
@@ -353,6 +366,16 @@ function create(opts) {
353
366
 
354
367
  if (methods.indexOf(req.method) === -1) return next();
355
368
 
369
+ // requireJsonContentType — refuse before the token check.
370
+ if (requireJsonCt) {
371
+ var ct = req.headers && req.headers["content-type"];
372
+ var bare = (typeof ct === "string" ? ct.split(";")[0].trim().toLowerCase() : "");
373
+ if (bare !== "application/json") {
374
+ _emitDenied(req, "non-JSON content-type: " + (bare || "<absent>"));
375
+ return _writeReject(res, "CSRF: state-changing requests require Content-Type: application/json.");
376
+ }
377
+ }
378
+
356
379
  // Origin / Referer cross-check (defense-in-depth alongside the
357
380
  // double-submit token). Refuses cross-origin state-changing
358
381
  // requests even when the token is valid (e.g. operator-mistaken
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.7.84",
3
+ "version": "0.7.86",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:4072392e-af80-4cf0-a9f7-095c0d6638e0",
5
+ "serialNumber": "urn:uuid:256e94d1-19a3-4626-8930-d068f5ff939f",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-06T06:11:44.785Z",
8
+ "timestamp": "2026-05-06T06:31:01.232Z",
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.7.84",
22
+ "bom-ref": "@blamejs/core@0.7.86",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.7.84",
25
+ "version": "0.7.86",
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.7.84",
29
+ "purl": "pkg:npm/%40blamejs/core@0.7.86",
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.7.84",
57
+ "ref": "@blamejs/core@0.7.86",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]