@blamejs/core 0.7.82 → 0.7.84
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/lib/auth/oauth.js +107 -0
- package/lib/crypto.js +41 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
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.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
|
+
|
|
13
|
+
- **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.
|
|
14
|
+
|
|
11
15
|
- **0.7.82** (2026-05-06) — `b.mail.bimi` — BIMI record builder + verifier (RFC 9091). BIMI publishes a sender's brand logo URL in DNS so receiving MTAs can render it next to the message in supported clients (Gmail, Yahoo, Apple Mail). **`b.mail.bimi.recordShape({ logoUrl, vmcUrl?, selector? })`** produces the canonical `v=BIMI1; l=https://...; a=https://...` TXT-record string per RFC 9091 §4. Both `l=` (SVG logo URL) and `a=` (Verified Mark Certificate URL per RFC 9091 §6) are HTTPS-required (refuses `http://`). All field values are CR/LF/NUL/semicolon-screened so a hostile URL can't inject a record-separator into the published TXT. **`b.mail.bimi.fetchPolicy(domain, { selector?, dnsLookup? })`** queries `<selector>._bimi.<domain>` (default selector `"default"`) and returns the structured record `{ v, l, a }` or `null` when no policy is published / record is malformed. **`b.mail.bimi.parseRecord(text)`** parses any operator-supplied TXT body — semicolon-separated `key=value` pairs per RFC 9091 §4. The framework does NOT validate the SVG / VMC contents against the RFC §5/§6 profiles — operators feed those to their own asset pipeline; the fetch primitive is a thin DNS lookup that returns the structured record so an operator dashboard or SMTP send-time preflight can verify the publication. BIMI is layered on a passing DMARC posture (the receiver requires DMARC quarantine/reject); operators with the existing `b.mail.dmarc` posture set up benefit from BIMI rendering immediately on receivers that honor it.
|
|
12
16
|
|
|
13
17
|
- **0.7.81** (2026-05-06) — `b.middleware.hostAllowlist` — DNS rebinding defense. Refuses requests whose `Host` header doesn't match the operator-supplied allowlist; the DNS rebinding chain (attacker DNS flips evil.com → 127.0.0.1, browser still believes the URL string says "evil.com" so same-origin policy lets the JS read the response, but the operator's localhost is what actually serves) is closed by checking the post-DNS-resolution `Host` header on the framework's side. **`b.middleware.hostAllowlist({ hosts, denyStatus?, denyBody?, audit? })`** — operators pass an allowlist of canonical Host values (with or without port). Wildcard-leading entries (`*.example.com`) match any single label; `app.sub.example.com` does NOT match `*.example.com` (multi-label rejected by design — a wildcard certificate authority issues only single-label intermediate). Entries without a port match any port; entries with a port require exact match. Default `denyStatus: 421` (RFC 7540 §9.1.2 "Misdirected Request"); default `denyBody: "Misdirected Request"`. Audit emits `network.host_allowlist.denied` with the reason (`missing-host` / `host-not-in-allowlist`) and the actual Host value for triage. Operators running explicitly-public services that accept arbitrary subdomains (multi-tenant forum shapes) skip this middleware entirely; there's no per-request opt-out.
|
package/lib/auth/oauth.js
CHANGED
|
@@ -349,6 +349,10 @@ function create(opts) {
|
|
|
349
349
|
userinfoEndpoint: opts.userinfoEndpoint || (preset && preset.userinfoEndpoint) || null,
|
|
350
350
|
revocationEndpoint: opts.revocationEndpoint || (preset && preset.revocationEndpoint) || null,
|
|
351
351
|
jwksUri: opts.jwksUri || (preset && preset.jwksUri) || null,
|
|
352
|
+
endSessionEndpoint: opts.endSessionEndpoint || (preset && preset.endSessionEndpoint) || null,
|
|
353
|
+
pushedAuthorizationRequestEndpoint:
|
|
354
|
+
opts.pushedAuthorizationRequestEndpoint ||
|
|
355
|
+
(preset && preset.pushedAuthorizationRequestEndpoint) || null,
|
|
352
356
|
};
|
|
353
357
|
|
|
354
358
|
// Discovery + JWKS caches use b.cache.create + .wrap so concurrent
|
|
@@ -422,6 +426,8 @@ function create(opts) {
|
|
|
422
426
|
userinfoEndpoint: "userinfo_endpoint",
|
|
423
427
|
revocationEndpoint: "revocation_endpoint",
|
|
424
428
|
jwksUri: "jwks_uri",
|
|
429
|
+
endSessionEndpoint: "end_session_endpoint",
|
|
430
|
+
pushedAuthorizationRequestEndpoint: "pushed_authorization_request_endpoint",
|
|
425
431
|
})[name];
|
|
426
432
|
var endpoint = config[snake];
|
|
427
433
|
if (!endpoint) {
|
|
@@ -697,6 +703,105 @@ function create(opts) {
|
|
|
697
703
|
return { header: header, claims: payload };
|
|
698
704
|
}
|
|
699
705
|
|
|
706
|
+
// ---- OIDC RP-Initiated Logout (OpenID Connect Session Mgmt 1.0) ----
|
|
707
|
+
//
|
|
708
|
+
// The IdP exposes an `end_session_endpoint` in its discovery doc;
|
|
709
|
+
// the RP-initiated logout flow redirects the user to that endpoint
|
|
710
|
+
// with the id_token_hint + post_logout_redirect_uri so the IdP
|
|
711
|
+
// terminates the IdP session and bounces the user back to the
|
|
712
|
+
// operator's app. Operators wire this on their /logout route.
|
|
713
|
+
async function endSessionUrl(uopts) {
|
|
714
|
+
uopts = uopts || {};
|
|
715
|
+
var endpoint;
|
|
716
|
+
try { endpoint = await _resolveEndpoint("endSessionEndpoint"); }
|
|
717
|
+
catch (_e) {
|
|
718
|
+
throw new OAuthError("auth-oauth/no-end-session-endpoint",
|
|
719
|
+
"endSessionUrl: IdP discovery doc has no end_session_endpoint " +
|
|
720
|
+
"(set opts.endSessionEndpoint on create() if the IdP doesn't publish it)");
|
|
721
|
+
}
|
|
722
|
+
var params = new URLSearchParams();
|
|
723
|
+
if (uopts.idTokenHint) params.set("id_token_hint", uopts.idTokenHint);
|
|
724
|
+
if (uopts.postLogoutRedirectUri) {
|
|
725
|
+
params.set("post_logout_redirect_uri", uopts.postLogoutRedirectUri);
|
|
726
|
+
}
|
|
727
|
+
if (uopts.state) params.set("state", uopts.state);
|
|
728
|
+
if (uopts.logoutHint) params.set("logout_hint", uopts.logoutHint);
|
|
729
|
+
if (uopts.uiLocales) params.set("ui_locales", uopts.uiLocales);
|
|
730
|
+
if (uopts.clientId !== false) params.set("client_id", clientId);
|
|
731
|
+
if (uopts.extraParams && typeof uopts.extraParams === "object") {
|
|
732
|
+
var ek = Object.keys(uopts.extraParams);
|
|
733
|
+
for (var i = 0; i < ek.length; i++) params.set(ek[i], String(uopts.extraParams[ek[i]]));
|
|
734
|
+
}
|
|
735
|
+
var qs = params.toString();
|
|
736
|
+
if (qs.length === 0) return endpoint;
|
|
737
|
+
var sep = endpoint.indexOf("?") === -1 ? "?" : "&";
|
|
738
|
+
return endpoint + sep + qs;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// ---- OAuth 2.0 Pushed Authorization Requests (RFC 9126) ----
|
|
742
|
+
//
|
|
743
|
+
// PAR: the client POSTs the authorization-request parameters
|
|
744
|
+
// directly to the IdP's PAR endpoint (mTLS or client-secret
|
|
745
|
+
// authenticated) and gets back a `request_uri` it then puts in the
|
|
746
|
+
// browser-side redirect to /authorize. Defends against parameter
|
|
747
|
+
// tampering by an MITM at the user-agent + against URL-length
|
|
748
|
+
// overflow on long authorization requests.
|
|
749
|
+
async function pushAuthorizationRequest(uopts) {
|
|
750
|
+
uopts = uopts || {};
|
|
751
|
+
var endpoint;
|
|
752
|
+
try { endpoint = await _resolveEndpoint("pushedAuthorizationRequestEndpoint"); }
|
|
753
|
+
catch (_e) {
|
|
754
|
+
throw new OAuthError("auth-oauth/no-par-endpoint",
|
|
755
|
+
"pushAuthorizationRequest: IdP discovery doc has no " +
|
|
756
|
+
"pushed_authorization_request_endpoint (set opts.pushedAuthorizationRequestEndpoint " +
|
|
757
|
+
"on create() if the IdP doesn't publish it)");
|
|
758
|
+
}
|
|
759
|
+
// Build the same param set authorizationUrl would emit, then POST
|
|
760
|
+
// it to PAR instead of putting it in the redirect URL.
|
|
761
|
+
var state = uopts.state || _generateRandomToken(STATE_NONCE_BYTES);
|
|
762
|
+
var nonce = uopts.nonce || (isOidc ? _generateRandomToken(STATE_NONCE_BYTES) : null);
|
|
763
|
+
var pkceVals = _generatePkce();
|
|
764
|
+
var body = new URLSearchParams();
|
|
765
|
+
body.set("response_type", "code");
|
|
766
|
+
body.set("client_id", clientId);
|
|
767
|
+
body.set("redirect_uri", redirectUri);
|
|
768
|
+
body.set("scope", scope.join(" "));
|
|
769
|
+
body.set("state", state);
|
|
770
|
+
if (nonce) body.set("nonce", nonce);
|
|
771
|
+
body.set("code_challenge", pkceVals.challenge);
|
|
772
|
+
body.set("code_challenge_method", "S256");
|
|
773
|
+
if (responseMode) body.set("response_mode", responseMode);
|
|
774
|
+
if (uopts.prompt) body.set("prompt", uopts.prompt);
|
|
775
|
+
if (uopts.loginHint) body.set("login_hint", uopts.loginHint);
|
|
776
|
+
if (uopts.maxAge != null) body.set("max_age", String(uopts.maxAge));
|
|
777
|
+
if (clientSecret) body.set("client_secret", clientSecret);
|
|
778
|
+
if (uopts.extraParams && typeof uopts.extraParams === "object") {
|
|
779
|
+
var ek = Object.keys(uopts.extraParams);
|
|
780
|
+
for (var i = 0; i < ek.length; i++) body.set(ek[i], String(uopts.extraParams[ek[i]]));
|
|
781
|
+
}
|
|
782
|
+
var rv = await _postForm(endpoint, body);
|
|
783
|
+
if (!rv || typeof rv.request_uri !== "string" || rv.request_uri.length === 0) {
|
|
784
|
+
throw new OAuthError("auth-oauth/par-bad-response",
|
|
785
|
+
"pushAuthorizationRequest: IdP did not return a request_uri (got " +
|
|
786
|
+
JSON.stringify(rv).slice(0, 200) + ")"); // allow:raw-byte-literal — error-message snippet length
|
|
787
|
+
}
|
|
788
|
+
// Build the browser-side redirect URL: /authorize?client_id=...&request_uri=...
|
|
789
|
+
var authzEndpoint = await _resolveEndpoint("authorizationEndpoint");
|
|
790
|
+
var qs = new URLSearchParams();
|
|
791
|
+
qs.set("client_id", clientId);
|
|
792
|
+
qs.set("request_uri", rv.request_uri);
|
|
793
|
+
var sep = authzEndpoint.indexOf("?") === -1 ? "?" : "&";
|
|
794
|
+
return {
|
|
795
|
+
url: authzEndpoint + sep + qs.toString(),
|
|
796
|
+
state: state,
|
|
797
|
+
nonce: nonce,
|
|
798
|
+
verifier: pkceVals.verifier,
|
|
799
|
+
challenge: pkceVals.challenge,
|
|
800
|
+
requestUri: rv.request_uri,
|
|
801
|
+
expiresIn: typeof rv.expires_in === "number" ? rv.expires_in : null,
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
|
|
700
805
|
return {
|
|
701
806
|
authorizationUrl: authorizationUrl,
|
|
702
807
|
exchangeCode: exchangeCode,
|
|
@@ -705,6 +810,8 @@ function create(opts) {
|
|
|
705
810
|
revokeToken: revokeToken,
|
|
706
811
|
verifyIdToken: verifyIdToken,
|
|
707
812
|
discover: _discover,
|
|
813
|
+
endSessionUrl: endSessionUrl,
|
|
814
|
+
pushAuthorizationRequest: pushAuthorizationRequest,
|
|
708
815
|
// Diagnostic / power-user surface
|
|
709
816
|
issuer: issuer,
|
|
710
817
|
clientId: clientId,
|
package/lib/crypto.js
CHANGED
|
@@ -95,6 +95,46 @@ function kdf(input, outputLength) { return hash(input, "shake256", outputLength)
|
|
|
95
95
|
function generateBytes(byteLength) { return Buffer.from(random(byteLength)); }
|
|
96
96
|
function generateToken(byteLength) { return random(byteLength || 32).toString("hex"); }
|
|
97
97
|
|
|
98
|
+
// ---- Subresource Integrity (W3C SRI 1.0) ----
|
|
99
|
+
//
|
|
100
|
+
// b.crypto.sri(content, { algorithm? }) — returns a `sha###-base64`
|
|
101
|
+
// integrity attribute string operators paste into <script integrity="...">
|
|
102
|
+
// or <link integrity="..."> tags. Defends against CDN compromise + ISP
|
|
103
|
+
// MITM injection — the browser refuses to load the resource when its
|
|
104
|
+
// hash diverges from the integrity attribute.
|
|
105
|
+
//
|
|
106
|
+
// W3C SRI 1.0 §3.2 lists sha256 / sha384 / sha512 as the supported
|
|
107
|
+
// digest algorithms; sha384 is the recommended default (collision
|
|
108
|
+
// margin without sha512's 64-byte overhead).
|
|
109
|
+
//
|
|
110
|
+
// b.crypto.sri(scriptBuffer, { algorithm: "sha384" })
|
|
111
|
+
// → "sha384-AbCdEf...="
|
|
112
|
+
//
|
|
113
|
+
// b.crypto.sri(["a", "b"], { algorithm: "sha384" }) // array → multi-hash
|
|
114
|
+
// → "sha384-X1... sha384-X2..." (per W3C §3.3 multi-integrity)
|
|
115
|
+
var SRI_ALGORITHMS = { "sha256": "sha256", "sha384": "sha384", "sha512": "sha512" };
|
|
116
|
+
|
|
117
|
+
function sri(content, opts) {
|
|
118
|
+
opts = opts || {};
|
|
119
|
+
var algorithm = (opts.algorithm || "sha384").toLowerCase();
|
|
120
|
+
if (!SRI_ALGORITHMS[algorithm]) {
|
|
121
|
+
throw new Error("crypto.sri: unsupported algorithm '" + algorithm +
|
|
122
|
+
"' (W3C SRI 1.0 §3.2 supports sha256/sha384/sha512)");
|
|
123
|
+
}
|
|
124
|
+
// Array input — emit multiple integrity tokens space-separated per
|
|
125
|
+
// W3C §3.3 (browser picks the strongest one it recognizes).
|
|
126
|
+
if (Array.isArray(content)) {
|
|
127
|
+
return content.map(function (c) { return sri(c, opts); }).join(" ");
|
|
128
|
+
}
|
|
129
|
+
var buf;
|
|
130
|
+
if (Buffer.isBuffer(content)) buf = content;
|
|
131
|
+
else if (typeof content === "string") buf = Buffer.from(content, "utf8");
|
|
132
|
+
else if (content instanceof Uint8Array) buf = Buffer.from(content);
|
|
133
|
+
else throw new Error("crypto.sri: content must be a Buffer, Uint8Array, string, or array of those");
|
|
134
|
+
var digest = nodeCrypto.createHash(algorithm).update(buf).digest("base64");
|
|
135
|
+
return algorithm + "-" + digest;
|
|
136
|
+
}
|
|
137
|
+
|
|
98
138
|
// ---- Key generation ----
|
|
99
139
|
function generateEncryptionKeyPair() {
|
|
100
140
|
var mlkem = generateKeyPair("ml-kem-1024");
|
|
@@ -454,6 +494,7 @@ var SUPPORTED_KEM_ALGORITHMS = Object.freeze([
|
|
|
454
494
|
]);
|
|
455
495
|
|
|
456
496
|
module.exports = {
|
|
497
|
+
sri: sri,
|
|
457
498
|
// Hashing
|
|
458
499
|
sha3Hash: sha3Hash,
|
|
459
500
|
hmacSha3: hmacSha3,
|
package/package.json
CHANGED
package/sbom.cyclonedx.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.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:4072392e-af80-4cf0-a9f7-095c0d6638e0",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
8
|
+
"timestamp": "2026-05-06T06:11:44.785Z",
|
|
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.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.7.84",
|
|
23
23
|
"type": "library",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.7.
|
|
25
|
+
"version": "0.7.84",
|
|
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.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.7.84",
|
|
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.
|
|
57
|
+
"ref": "@blamejs/core@0.7.84",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|