@blamejs/core 0.8.69 → 0.8.72
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 +3 -0
- package/README.md +158 -13
- package/index.js +2 -0
- package/lib/audit.js +1 -0
- package/lib/auth/oauth.js +202 -3
- package/lib/compliance.js +76 -0
- package/lib/data-act.js +328 -0
- package/lib/fapi2.js +109 -1
- package/lib/mcp.js +322 -0
- package/lib/middleware/cors.js +23 -1
- package/lib/middleware/require-aal.js +2 -0
- package/lib/middleware/require-auth.js +11 -3
- package/lib/middleware/require-step-up.js +3 -0
- package/lib/middleware/security-headers.js +14 -0
- package/package.json +2 -1
- package/sbom.cyclonedx.json +8 -8
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,9 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.8.x
|
|
10
10
|
|
|
11
|
+
- v0.8.72 (2026-05-10) — fuzz harness against the parser / validator surface + smoke-time fuzz-coverage gate. New `fuzz/` directory ships hand-rolled fuzz harnesses against the 11 highest-value adversarial-input primitives — `b.safeJson.parse`, `b.safeUrl.parse`, `b.safeJsonPath.validateExpression`, `b.guardCsv.validate`, `b.guardHtml.validate`, `b.guardJson.parse`, `b.guardYaml.parse`, `b.guardXml.validate`, `b.guardSvg.validate`, `b.guardMarkdown.validate`, `b.guardEmail.validateMessage`. Each harness generates random / mutated / bidi-salted / control-char-salted inputs against a per-target seed corpus, runs until `FUZZ_BUDGET_MS` elapses (default 30s; CI: 60s on PR / 300s on schedule), and fails with a reproducer when the target throws an unexpected error (vs. an operator-friendly framework error code in the documented `domain/error` or `domain.error` shape). Native `TypeError` with input-shape messaging, `SyntaxError`, and `RangeError` matching the depth/length/cap contract are accepted; everything else is a finding. New `.github/workflows/fuzz.yml` runs the harness in matrix on every PR touching `lib/` or `fuzz/` and on a daily 05:17 UTC schedule. New Layer 0 detector `testParserPrimitivesHaveFuzzHarness` in `test/layer-0-primitives/codebase-patterns.test.js` enforces that every `lib/safe-*.js` and `lib/guard-*.js` file has a corresponding `fuzz/<name>.fuzz.js` OR an explicit `FUZZ_NOT_REQUIRED` allowlist entry with reason — so a future parser primitive can't silently ship without fuzz coverage. `npm run fuzz` runs every harness sequentially via `fuzz/_run-all.js` for local dev. README OpenSSF Scorecard badge URL fixed (`api.scorecards.dev` → `api.scorecard.dev` — plural-singular typo).
|
|
12
|
+
- v0.8.71 (2026-05-10) — CI green-up for v0.8.70. The v0.8.70 npm-publish workflow's cosign-sign-blob step couldn't resolve `sigstore/cosign-installer@d7d6e07b3e89342f1d8bcd4f76c2fa5a9d1a1f7e` — the SHA was a typo, not a real commit on the action's repo. Replaced with the actual v3.7.0 commit SHA `dc72c7d5c4d10cd6bcb8cf6e3fd625a9e5e537da`. No primitive surface change versus v0.8.70.
|
|
13
|
+
- v0.8.70 (2026-05-10) — six-batch additive surface across OAuth/OIDC, FAPI 2.0, browser hardening, MCP safety, compliance postures, and supply-chain. **OAuth/OIDC**: `b.auth.oauth.parseCallback(query, opts?)` validates RFC 9207 AS Issuer Identifier (refuses iss-mismatch and OP `error=` redirects, optional `requireIssParam` to refuse missing iss), `parseJarmResponse(jwt, opts?)` decodes OAuth 2.0 JARM signed authorization responses, and `refreshAccessToken(token, { seen })` accepts an operator-supplied callback that refuses replayed refresh tokens before any HTTP call (RFC 9700 §4.13 / OAuth 2.1 §6.1 one-time-use rotation; returns `refreshTokenRotated: true` on success). **FAPI 2.0 runtime**: `b.fapi2.assertCallback(query)` refuses missing iss when `fapi-2.0` posture is set (and refuses bare-param when `fapi-2.0-message-signing` is set, requiring JARM `response`); `b.fapi2.assertAuthzRequest(authzParams)` refuses non-JAR (bare-param) authorization requests under FAPI 2.0. New `fapi-2.0-message-signing` posture registered. **Browser hardening**: `Permissions-Policy` defaults extend with `storage-access=()`, `browsing-topics=()`, `private-aggregation=()`, `controlled-frame=()`, `captured-surface-control=()`; `b.middleware.cors` gains `allowPrivateNetwork` opt + Private Network Access preflight handling (refuses `Access-Control-Request-Private-Network` by default, sets `Access-Control-Allow-Private-Network: true` when opted in); `b.middleware.requireAuth` / `requireAal` / `requireStepUp` 401 responses now set `Cache-Control: no-store` (RFC 9111 §5.2.2.5). **MCP safety + LLM07/08**: `b.mcp.toolResult.sanitize(result, opts?)` runs prompt-injection regex + dangerous-HTML detection + URL-allowlist on tool outputs (modes `refuse` / `sanitize` / `audit-only`); `b.mcp.capability.create(scopes)` + `satisfiedBy(granted)` formalize least-privilege capability checks; `b.mcp.validateToolInput(toolName, input, schema)` enforces a JSON Schema 2020-12 subset on tool inputs (`type` / `properties` / `required` / `items` / `enum` / `const` / `minLength` / `maxLength` / `minimum` / `maximum`). **Compliance postures**: `modpa` (Maryland Online Data Privacy Act, US-MD privacy), `nydfs-500` (NY DFS Cybersecurity Regulation, US-NY financial), `hipaa-2026` (HHS Final Rule effective 2026, US health), `quebec-25` (Quebec Law 25, CA-QC privacy), and `fapi-2.0-message-signing` (INTL financial). **EU Data Act** (Regulation 2023/2854): new `b.dataAct` primitive — `declareProduct`, `recordUserAccess`, `shareWithThirdParty` (Art 32 §1 refuses sharing with DMA designated gatekeepers without an audited override `acceptGatekeeper.reason`), `recordSwitchRequest` (Art 28 §3 caps notice period at 30 days). **Supply chain**: SBOM bumped to CycloneDX 1.6; npm-publish workflow now runs OSV-Scanner with `--fail-on-vuln=HIGH`, signs the SBOM via Sigstore cosign keyless flow (attaches `.sigstore` bundle to the GH release alongside the JSON); `scripts/publish-dep-confusion-placeholder.sh` claims unscoped names (`blamejs`, `blame-js`, `blamejs-core`) on npm with placeholder packages that exit-1 + redirect to canonical `@blamejs/core` (defends against dependency-confusion typosquats — manual, run on maintainer rotation, refuses overwrite when a different owner already holds the name).
|
|
11
14
|
- v0.8.69 (2026-05-10) — test-side `waitUntil` helper + CLAUDE.md §11b convention. Every recurring "test passes alone, fails under SMOKE_PARALLEL=64 / macOS GitHub-Actions runner" flake we've fought across v0.8.55 (rate-limit-cluster), v0.8.60 (watcher), v0.8.63 / v0.8.65 / v0.8.68 (log-stream-otlp / sandbox) was the same root cause: a fixed-budget `setTimeout(r, N)` sleep too short for runner-contention reality. New `test/helpers/wait.js` ships `waitUntil(predicate, opts?)` (polls every `intervalMs` default 25ms up to `timeoutMs` default 5000ms, exits early when predicate truthy, throws labeled error on timeout) + `waitUntilEqual(getter, expected)` convenience wrapper. `test/helpers/index.js` re-exports both. Refactored `test/layer-0-primitives/log-stream-otlp.test.js`'s "collector saw retries" gate (the most-recently-flaked one) to use `waitUntil({ failCount >= 2, dropEvents.length === 1 })` instead of `_sleep(200)` — fast platforms exit in ~30ms, contended platforms get the full budget. CLAUDE.md §11b documents the convention: when you find yourself bumping a hand-tuned sleep to fix a CI flake, that's the smell — convert to `waitUntil`. Future flake fixes update one timeout ceiling instead of N inline budgets.
|
|
12
15
|
- v0.8.68 (2026-05-10) — `b.watcher` polling backend for environments where `fs.watch` doesn't deliver events. Pre-v0.8.68 the watcher used `fs.watch(root, { recursive: true })` exclusively — works on real Linux/macOS/Windows kernels but silent on filesystems where the kernel→userspace event bridge doesn't exist: Docker Desktop bind-mounts on Windows / macOS hosts (gRPC-FUSE / VirtioFS doesn't propagate inotify events from the Linux container's mount through to the host fs the operator runs node on), NFS / SMB mounts, some FUSE filesystems. Add `mode: "fs" | "poll"` (default `"fs"`) — when `mode: "poll"`, the watcher walks the tree on a fixed interval and diffs against the previous snapshot. New file / mtime-change / size-change → `onChange` via the existing debounce + ignore + lstat dispatch; missing path → `onDelete`. `pollIntervalMs` (default 1s) sets cadence; `pollMaxFiles` (default 50000) caps the per-tick walk so a misconfigured root can't stall the event loop stat'ing 100k files every second — overflow refuses with `watcher/poll-overflow`. Symlinks skipped (matches fs.watch path). The initial walk happens synchronously in `create()` so the first event fires only on real post-start changes (not on pre-existing files). `_flushForTest()` runs one synchronous tick + drains pending debounces so polling tests don't have to sleep `pollIntervalMs`. Returned handle gains `.mode` for operator introspection. fs.watch-backend error messages now suggest `mode: "poll"` as the fallback. New Layer 0 polling tests cover create / modify / delete detection across nested directories + mode validation + pollMaxFiles overflow.
|
|
13
16
|
- v0.8.67 (2026-05-10) — SAML XMLDSig Reference Transforms (`enveloped-signature` + per-Reference c14n) + full IdP-emitted SAML round-trip in the federation-auth integration test. Pre-v0.8.67 `b.auth.saml.sp.verifyResponse` only honored the SignedInfo's `CanonicalizationMethod`; it didn't process the `<ds:Transforms>` block on the Reference. Real-world IdP-signed responses (Keycloak, ADFS, Okta) attach `http://www.w3.org/2000/09/xmldsig#enveloped-signature` (strip the `<Signature>` child of the referenced element before c14n) and a per-Reference `xml-exc-c14n#` Transform — without them, the digest computed over the assertion-including-signature never matches the signed-then-signature-injected reality and verifyResponse rejected legitimate IdP responses. `_verifyXmldsig` now reads the Transforms list, applies enveloped-signature by filtering the parsed-tree's `<Signature>` element children before canonicalization, and honors the per-Reference c14n choice (with vs without comments). The single-match-by-ID invariant + signature-wrapping defense moves into the saml.js path directly so the modified subtree (signature stripped) is the one that gets canonicalized + digested. `test/integration/federation-auth.test.js` now drives Keycloak's HTML login form via cookie-jar curl-equivalent (no headless browser needed), captures the IdP-signed SAMLResponse, fetches the IdP signing certificate from `/protocol/saml/descriptor`, hands the response to `sp.verifyResponse(b64, { expectedInResponseTo })`, and asserts the extracted `nameId` / `issuer` / `audience` / `inResponseTo` match the realm's signed claims. Verified end-to-end: Keycloak alice user → SAML AuthnRequest → login form POST → signed Response → `sp.verifyResponse()` → `{ nameId: "alice", issuer: <realm>, audience: <SP entityID>, attributes: { Role: "default-roles-..." } }`. Unsupported Transform algorithms refuse loudly via `auth-saml/unsupported-transform`. No primitive surface change versus v0.8.66 (the Transforms processing is internal to `_verifyXmldsig`).
|
package/README.md
CHANGED
|
@@ -1,9 +1,26 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
<img src="assets/BlameJS_Logo.png" alt="blamejs" width="220" />
|
|
4
|
+
|
|
1
5
|
# blamejs
|
|
2
6
|
|
|
3
7
|
**The Node framework that owns its stack.**
|
|
4
8
|
|
|
5
9
|
One install. One upgrade path. One place to look when something breaks — no blame to pass between forty transitive dependencies you didn't choose.
|
|
6
10
|
|
|
11
|
+
[](https://www.npmjs.com/package/@blamejs/core)
|
|
12
|
+
[](https://www.npmjs.com/package/@blamejs/core)
|
|
13
|
+
[](https://github.com/blamejs/blamejs/actions/workflows/ci.yml)
|
|
14
|
+
[](https://github.com/blamejs/blamejs/releases)
|
|
15
|
+
[](https://scorecard.dev/viewer/?uri=github.com/blamejs/blamejs)
|
|
16
|
+
[](https://slsa.dev)
|
|
17
|
+
[](https://www.apache.org/licenses/LICENSE-2.0)
|
|
18
|
+
[](https://nodejs.org)
|
|
19
|
+
[](#why-blamejs)
|
|
20
|
+
[](#why-blamejs)
|
|
21
|
+
|
|
22
|
+
</div>
|
|
23
|
+
|
|
7
24
|
---
|
|
8
25
|
|
|
9
26
|
## Why blamejs
|
|
@@ -41,19 +58,147 @@ var b = require("@blamejs/core");
|
|
|
41
58
|
|
|
42
59
|
The framework bundles the surface a typical Node app reaches for. Every primitive listed is callable today; nothing is a stub.
|
|
43
60
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
- **
|
|
47
|
-
- **
|
|
48
|
-
- **
|
|
49
|
-
- **
|
|
50
|
-
- **
|
|
51
|
-
- **
|
|
52
|
-
- **
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
- **
|
|
56
|
-
- **
|
|
61
|
+
### Data layer
|
|
62
|
+
|
|
63
|
+
- **SQLite with sealed-by-default columns** — `b.db`, migrations, seeders, atomic-file writes
|
|
64
|
+
- **Chainable query builder** — atomic `.increment(col, delta)`, closure-form `.whereGroup` / top-level `.orWhere` OR composition, `.search(fields, term)` LIKE-OR with safe `%`/`_` ESCAPE handling, `.paginate(opts)` returning `{ items, total, page, totalPages }`
|
|
65
|
+
- **Mongo-style document-store facade** — `b.db.collection(name, opts?)` with `$set` / `$inc` / `$unset` / `$eq` / `$ne` / `$gt` / `$gte` / `$lt` / `$lte` / `$in` / `$like`; schemaless-document opts via `overflow: "<col>"` (folds unknown fields into a JSON-text column; rewrites `WHERE` on virtual fields to `JSON_EXTRACT`), `jsonColumns: [...]` (auto-stringify on write + parse via `b.safeJson` on read), `sealedFields: { email: "emailHash" }` (co-locates a `b.cryptoField` sealed-column / derived-hash declaration so plaintext lookups auto-rewrite to hash-column lookups)
|
|
66
|
+
- **DB lifecycle** — in-memory encrypted snapshot via `b.db.snapshot()`; standalone encrypted-DB-file lifecycle (`b.db.fileLifecycle({ dataDir, vault })` — decrypt-to-tmpfs, periodic re-encrypt flush, graceful shutdown — same envelope as `b.db`, no schema/audit-chain coupling); `db.init` opt-outs `frameworkTables: false` / `auditSigning: false` and path overrides `encryptedDbPath` / `encryptedDbName` / `dbKeyPath`
|
|
67
|
+
- **External RDBMS** — bring-your-own Postgres / MySQL with pool tuning + role-aware connect + read-replica routing (`b.externalDb`); declarative role-narrowed views and Postgres row-level-security migrations (`b.db.declareView`, `b.db.declareRowPolicy`)
|
|
68
|
+
- **Object store** — S3 / R2 / B2 / GCS / Azure with multipart upload + SSE + bucket-ops (create / delete / list / lifecycle / CORS); S3 Object Lock + per-object retention + legal hold for write-once-read-many compliance workloads (`b.storage`, `b.objectStore`)
|
|
69
|
+
- **Queues + cache** — durable queue with priority + cron + flows on local SQLite, shared Redis, OR AWS SQS via SigV4 + AWSJsonProtocol_1.0 (`b.queue`, `b.jobs`); cluster-shared cache (`b.cache`)
|
|
70
|
+
### Identity & access
|
|
71
|
+
|
|
72
|
+
- **Passwords** — Argon2id + policy primitive (`b.auth.password`); NIST 800-63B / PCI-DSS 4.0 / HIPAA-AAL2 profiles; HaveIBeenPwned k-anonymity breach check; length / context / dictionary / complexity rules; rotation + history
|
|
73
|
+
- **Multi-factor + WebAuthn** — passkeys (WebAuthn), TOTP, JWT (PQ-default)
|
|
74
|
+
- **OAuth / OIDC RP** — `b.auth.oauth`
|
|
75
|
+
- RP-Initiated / Front-Channel / Back-Channel Logout 1.0 (`parseFrontchannelLogoutRequest` + `verifyBackchannelLogoutToken` with jti-replay defense)
|
|
76
|
+
- RFC 9207 AS Issuer Identifier validation on callbacks (`parseCallback` — refuses iss mismatch + OP `error=` redirect)
|
|
77
|
+
- OAuth 2.0 JARM signed-response decode (`parseJarmResponse`)
|
|
78
|
+
- One-time-use refresh-token rotation with operator-supplied replay-defense callback (RFC 9700 §4.13 / OAuth 2.1 §6.1 — `refreshAccessToken({ seen })`)
|
|
79
|
+
- **Federation / VC** — CIBA Core 1.0 (`b.auth.ciba`, poll/ping/push); OpenID Federation 1.0 trust chain + metadata_policy (`b.auth.openidFederation`); SAML 2.0 SP with XMLDSig signature-wrapping defense + RFC 9525 server-identity (`b.auth.saml`); OpenID4VCI 1.0 issuer (`b.auth.oid4vci`); OpenID4VP 1.0 verifier with DCQL (`b.auth.oid4vp`); SD-JWT VC with `key_attestation` extension (`b.auth.sdJwtVc`)
|
|
80
|
+
- **Sessions** — `b.session`
|
|
81
|
+
- PQC-sealed sid cookie (ML-KEM-1024 + P-384 hybrid + XChaCha20-Poly1305 wire envelope)
|
|
82
|
+
- `/24` IPv4 + `/64` IPv6 subnet binding via `fingerprintFields: ["clientIpPrefix"]` (carrier-roaming-safe)
|
|
83
|
+
- Pluggable storage via `b.session.useStore` + first-party `b.session.stores.localDbThin` (tmpfs-fast)
|
|
84
|
+
- Opaque-userId anonymous sessions via `create({ anonymous: true })`
|
|
85
|
+
- Idle / absolute timeouts, fingerprint drift detection + anomaly scoring, brute-force lockout
|
|
86
|
+
- **Authorization** — RBAC + per-role DB binding + role-spec `requireMfa` + per-route MFA freshness window + ABAC predicate registry (`b.permissions`); API keys with rotation (`b.apiKey`)
|
|
87
|
+
- **Workflow gates** — break-glass column gates with second-factor + audit (`b.breakGlass`); two-person-rule m-of-n approval with cooling-off lock + cancellation (`b.dualControl`)
|
|
88
|
+
- **Financial / Open Banking** — FAPI 2.0 Final composite posture (PAR + PKCE-S256 + DPoP-or-mTLS + RFC 9207); runtime enforcement helpers `b.fapi2.assertCallback` (refuses missing iss + bare-param under message-signing) and `b.fapi2.assertAuthzRequest` (refuses non-JAR); CFPB §1033 / FDX 6.0 consumer-financial-data-sharing wrapper (`b.fdx`)
|
|
89
|
+
- **Data-subject coordination** — cross-table export / rectify / erase / restrict / objection (`b.subject`, `b.subject.eraseHard`); subject-level legal-hold registry consulted by erase + retention paths (FRCP Rule 26/37(e), GDPR Art 17(3)(e), SEC Rule 17a-4, HIPAA §164.530(j)(2)) (`b.legalHold`)
|
|
90
|
+
- **Account safety** — adaptive bot-challenge staircase (`b.authBotChallenge`); session-to-device-posture binding with fail-closed verify (`b.sessionDeviceBinding`)
|
|
91
|
+
### Crypto
|
|
92
|
+
|
|
93
|
+
- **At-rest envelope** — envelope-versioned PQC (ML-KEM-1024 + P-384 hybrid, XChaCha20-Poly1305, SHAKE256); vault sealing (`b.crypto`, `b.vault`)
|
|
94
|
+
- **Field-level + crypto-shred** — `b.cryptoField.eraseRow`; per-column data residency tagging + per-row keys (`K_row = HKDF(K_table, rowId)`) so erasing the per-row key makes WAL / replica residuals undecryptable (`b.cryptoField.declareColumnResidency`, `b.cryptoField.declarePerRowKey`)
|
|
95
|
+
- **AAD-bound sealed columns** — AEAD tag tied to `(table, rowId, column, schemaVersion)`; copy-paste between rows or schema-version replay surfaces as refused decrypt (`b.vault.aad`)
|
|
96
|
+
- **Signed webhooks + API encryption** — SLH-DSA-SHAKE-256f default; ML-DSA-65 opt-in; ECIES API encryption (`b.webhook`, `b.crypto`)
|
|
97
|
+
- **HPKE / HTTP signatures** — RFC 9180 HPKE with ML-KEM-1024 + HKDF-SHA3-512 + ChaCha20-Poly1305 (`b.crypto.hpke`); RFC 9421 HTTP Message Signatures with derived components and ed25519 / ML-DSA-65 (`b.crypto.httpSig`)
|
|
98
|
+
- **TLS / channel binding** — RFC 9266 TLS-Exporter token-to-session pinning (`b.tlsExporter`); RFC 9162 CT v2 inclusion-proof verification (`b.network.tls.ct.verifyInclusion`); RFC 8555 ACME + RFC 9773 ARI for 47-day certs (`b.acme`); RFC 8470 0-RTT inbound posture refuse / replay-cache (`b.router.create({tls0Rtt})`); RFC 9794 SecP256r1MLKEM768 in preferred-group order (`b.network.tls.preferredGroups`)
|
|
99
|
+
- **mTLS CA** — pure-JS, issues clientAuth / serverAuth / dual-EKU certs with SAN; auto-detects highest-PQC signature alg (today ECDSA-P384-SHA384; self-upgrades to SLH-DSA / ML-DSA when X.509 ecosystem catches up); PQC TLS gates inbound + outbound (`b.mtlsCa`, `b.pqcGate`, `b.pqcAgent`)
|
|
100
|
+
### HTTP
|
|
101
|
+
|
|
102
|
+
- **Router + API specs** — schema-validated routes; OpenAPI publication (`b.openapi`) + AsyncAPI publication for event/streaming (`b.asyncapi`)
|
|
103
|
+
- **Middleware stack (wired by `createApp`)**
|
|
104
|
+
- CSRF protection
|
|
105
|
+
- CORS with W3C Private Network Access preflight refusal default + `allowPrivateNetwork` opt
|
|
106
|
+
- Rate-limit
|
|
107
|
+
- Security headers with `Permissions-Policy` defaults denying storage-access / browsing-topics / private-aggregation / controlled-frame
|
|
108
|
+
- CSP nonce
|
|
109
|
+
- Body parser
|
|
110
|
+
- Compression
|
|
111
|
+
- SSE
|
|
112
|
+
- Request log
|
|
113
|
+
- Threat-aware cookie parser (`b.middleware.cookies`)
|
|
114
|
+
- Request-time DB role binding (`b.middleware.dbRoleFor`)
|
|
115
|
+
- In-process CIDR fence (`b.middleware.networkAllowlist`)
|
|
116
|
+
- `Cache-Control: no-store` on every 401 from `requireAuth` / `requireAal` / `requireStepUp` per RFC 9111 §5.2.2.5
|
|
117
|
+
- **Outbound HTTP client** — HTTP/1.1 + HTTP/2 with SSRF gate (cloud-metadata IPs hard-denied; private / loopback / link-local overridable per call); scheme + userinfo + per-host destination allowlist; redirects, multipart, interceptors, progress, encrypted cookie jar (`b.httpClient`, `b.ssrfGuard`, `b.safeUrl`)
|
|
118
|
+
- **Network configurability (`b.network`)** — env-driven NTP / NTS (RFC 8915), IPv4/IPv6 NTP, DNS with IPv6 / DoH / DoT (private-CA pinning) / cache / lookup timeout; outbound HTTP proxy (`HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`); runtime DPI trust-store CA additions; application-level heartbeats; TCP socket defaults
|
|
119
|
+
- **Error pages** — operator-rendered, no app-frame leakage (`b.errorPage`)
|
|
120
|
+
### Defensive parsers
|
|
121
|
+
|
|
122
|
+
- **JSON / SQL / schema** — `b.safeJson` (with `maxKeys` cap defending CVE-2026-21717 V8 HashDoS), `b.safeBuffer`, `b.safeSql`, `b.safeSchema`
|
|
123
|
+
- **URL + path** — `b.safeUrl` (IDN mixed-script / homograph refuse); `b.safeJsonPath` (refuses filter `?(...)`, deep-scan `$..`, script-shape `(@.x)` for safe Postgres JSONB ops)
|
|
124
|
+
- **Document parsers** — `b.parsers` (XML / TOML / YAML / .env); `b.config` (schema-validated env)
|
|
125
|
+
- **File-type detection** — `b.fileType` magic-byte content classification with deny-on-upload categories (image / document / archive / executable / etc.)
|
|
126
|
+
### Content-safety gates
|
|
127
|
+
|
|
128
|
+
- **Composition contract** — `b.gateContract` uniform mode posture / hooks / forensic snapshot / decision cache / runtime cap
|
|
129
|
+
- **Document guards** — `b.guardCsv` (formula injection, dangerous-function denylist, bidi / homoglyph / dialect ambiguity, CSV-bombs); `b.guardHtml` (XSS / mXSS / DOM-clobbering, dangerous-tag + event-handler family, URL-scheme with entity-decode bypass, CSS-injection in style); `b.guardSvg` (script / foreignObject / animation href hijack / DOCTYPE / XXE / SVGZ / cross-origin `<use>` SSRF); `b.guardMarkdown` (URL schemes pre-render, CVE-2026-30838 dangerous-tag, ReDoS emphasis runs)
|
|
130
|
+
- **Structured data** — `b.guardJson` (prototype-pollution, dup keys, JSON5, depth/breadth caps); `b.guardYaml` (deserialization-tag RCE, billion-laughs aliases, Norway-problem); `b.guardXml` (XXE / billion-laughs / xi:include / signature wrapping; DOCTYPE refused at all profile levels)
|
|
131
|
+
- **Archive + filename** — `b.guardArchive` (zip-slip, symlink + hardlink escape, decompression bombs, duplicate-entry); `b.guardFilename` (path traversal raw + percent-encoded + overlong-UTF-8, null-byte, Windows reserved, NTFS ADS, RTLO bidi)
|
|
132
|
+
- **Email** — `b.guardEmail` (SMTP smuggling per CVE-2023-51764 / 51765 / 51766 class, CRLF header injection, IDN homograph, IP-literals, RFC 5321 length caps)
|
|
133
|
+
- **Profiles + postures** — every member ships strict / balanced / permissive plus hipaa / pci-dss / gdpr / soc2
|
|
134
|
+
- **Aggregator** — `b.guardAll` registry; every shipped guard ON by default; opt-out per guard with audited reason via `exceptFor: { name: { reason } }`. `b.fileUpload` and `b.staticServe` wire `b.guardAll.byExtension({ profile: "strict" })` + `b.guardFilename.gate({ profile: "strict" })` automatically — operator opts out via `contentSafety: null` / `filenameSafety: null` (audited)
|
|
135
|
+
### Communication
|
|
136
|
+
|
|
137
|
+
- **WebSockets (server)** — channel/room fan-out across cluster replicas; RFC 6455 §5.5 control-frame size + FIN enforcement on inbound (defends 1 MiB-PING-as-PONG amplification) (`b.websocket`, `b.websocketChannels`)
|
|
138
|
+
- **WebSockets (client)** — `b.wsClient` with PQC-TLS handshake, permessage-deflate negotiation with decompression-bomb cap, fatal UTF-8 validation, permanent-error classifier (skips reconnect on 4xx / accept mismatch / bad-subprotocol), exponential-backoff with full jitter
|
|
139
|
+
- **Pub/sub + events** — distributed pub/sub with cluster-table / Redis PUB/SUB / custom backends (`b.pubsub`); framework-emitted signal bus for breach / integrity events (`b.events`)
|
|
140
|
+
- **CloudEvents + SSE** — CloudEvents 1.0 envelope for AWS EventBridge / Knative / Azure Event Grid / Google Eventarc / CNCF (`b.cloudEvents`); Server-Sent Events with newline-injection refusal in `event:` / `id:` / `data:` / `Last-Event-ID` (CVE-2026-33128 / 29085 / 44217 class) (`b.sse`, `b.middleware.sse`)
|
|
141
|
+
- **Mail (outbound)** — multipart + attachments + DKIM + calendar invites; bounce intake (`b.mail`, `b.mailBounce`)
|
|
142
|
+
- **Mail (inbound auth)** — SPF / DMARC / ARC verify + ARC chain signing for relays (`b.mail.spf`, `b.mail.dmarc`, `b.mail.arc`)
|
|
143
|
+
- **Notifications** — generic dispatcher with operator-supplied transports (`b.notify`); TCPA / FCC 1:1 prior-express-written-consent + 10DLC carrier-shaped consent snapshot for SMS marketing (`b.tcpa10dlc`)
|
|
144
|
+
- **File uploads** — chunked with per-chunk SHA3-512 verification + atomic finalize + tombstone cleanup (`b.fileUpload`)
|
|
145
|
+
### AI / agentic
|
|
146
|
+
|
|
147
|
+
- **MCP (Model Context Protocol)** — `b.mcp.serverGuard` with bearer auth + redirect_uri allowlist + dynamic-register refusal + tool/resource allowlists (CVE-2026-33032 / CVE-2025-6514 / confused-deputy class)
|
|
148
|
+
- **MCP safety primitives**
|
|
149
|
+
- `b.mcp.toolResult.sanitize` — prompt-injection / dangerous-HTML / off-allowlist-URL detection (OWASP LLM07)
|
|
150
|
+
- `b.mcp.capability.create` — least-privilege capability scopes (OWASP LLM08)
|
|
151
|
+
- `b.mcp.validateToolInput` — JSON Schema 2020-12 input enforcement
|
|
152
|
+
- **GraphQL Federation** — `_service.sdl` trust-boundary with router-token + nonce store (`b.graphqlFederation`)
|
|
153
|
+
- **Prompt-injection classification** — OWASP LLM01:2025 / NIST COSAIS RFI (`b.ai.input.classify`)
|
|
154
|
+
- **Agent identity** — A2A signed agent-card primitive (Linux Foundation Agentic AI Foundation v1.x, ML-DSA-87) (`b.a2a`)
|
|
155
|
+
- **Content provenance** — C2PA 2.1 + California SB-942 / AB-853 manifest builder for AI-generated media (provider, model id + version, timestamp, content ID, signed) (`b.contentCredentials`)
|
|
156
|
+
### Compliance regimes
|
|
157
|
+
|
|
158
|
+
- **Posture coordinator** — `b.compliance` cascades operator-declared regime into retention / audit / db / cryptoField via POSTURE_DEFAULTS:
|
|
159
|
+
- **US** — `hipaa` / `hipaa-2026` / `pci-dss` / `sox-404` / `soc2` / `soc2-cc1.3` / `sec-cyber` / `sec-17a-4` / `finra-4511` / `fda-21cfr11` / `fda-annex-11` / `modpa` / `nydfs-500` / `staterramp`
|
|
160
|
+
- **EU / UK** — `gdpr` / `dora` / `nis2` / `cra` / `uk-g-cloud`
|
|
161
|
+
- **APAC + LATAM** — `dpdp` / `pipl-cn` / `lgpd-br` / `appi-jp` / `pdpa-sg` / `quebec-25` / `irap`
|
|
162
|
+
- **Financial / data-portability** — `fapi2` / `fapi-2.0-message-signing` / `fdx` / `dsr`
|
|
163
|
+
- **Other** — `bsi-c5` / `ens-es` / etc.
|
|
164
|
+
- **EU Data Act** — Regulation 2023/2854 connected-product data access workflow with DMA-gatekeeper share refusal (Art 32 §1) and 30-day switch-request notice cap (Art 28 §3) (`b.dataAct`)
|
|
165
|
+
- **Audit + segregation** — 21 CFR Part 11 §11.10(e) audit-content gate + §11.50(b) electronicSignature (`b.fda21cfr11`); PCI DSS 4.0 Req 10.4.1.1 daily-review automation (`b.auditDailyReview`); SOX §404 + SOC 2 CC1.3 segregation-of-duties via Postgres trigger DDL (`b.audit.bindActor`, `b.audit.assertSegregation`)
|
|
166
|
+
- **Change control + WORM** — m-of-n approver DDL change-control with maintenance-window + ML-DSA-87 signed proposals (`b.ddlChangeControl`); row-level WORM triggers boot-asserted under `sec-17a-4` / `finra-4511` / `fda-21cfr11` (`b.db.declareWorm`); dual-control physical delete + crypto-erase + REINDEX in one transaction (`b.db.declareRequireDualControl`, `b.db.eraseHard`)
|
|
167
|
+
- **Consumer-protection** — FTC click-to-cancel UX-parity attestation (`ftc-2024` / `ca-sb942` / `strict`) (`b.darkPatterns`)
|
|
168
|
+
- **Privacy / DSR** — GDPR Articles 15–22 / CCPA / CPRA / LGPD / PIPEDA data-subject-rights workflow (`b.dsr`); IAB TCF v2.3 consent-string parser + `disclosedVendors` validator (`b.iabTcf`); IAB MSPA / GPP universal-opt-out (USNAT / USCA / USVA / USCO / USCT / USUT) + GPC mirror (`b.iabMspa`); generic consent capture + withdrawal (`b.consent`)
|
|
169
|
+
- **Incident reporters** — EU DORA Article 17 ICT-incident workflow per Commission Delegated Regulation 2024/1772 (`b.dora`); EU NIS2 (`b.nis2`); EU Cyber Resilience Act SBOM + secure-software-attestation (`b.cra`); SEC Form 8-K Item 1.05 cybersecurity-incident materiality-disclosure (`b.secCyber`); incident lifecycle coordinator (`b.incident`)
|
|
170
|
+
- **Outbound DLP** — interceptor-installed on httpClient + mail + webhook with built-in detectors for PAN (Luhn), SSN, EIN, IBAN (mod-97), api-key shapes, PEM, SSH private keys, JWTs, AWS access keys, PHI composite; refuse / redact / audit-only verdicts under pci-dss / hipaa / fapi2 / soc2 / gdpr presets (`b.redact.installOutboundDlp`)
|
|
171
|
+
### Observability
|
|
172
|
+
|
|
173
|
+
- **Audit chain** — tamper-evident, SLH-DSA-signed checkpoints; CADF (ISO/IEC 19395:2017) envelope export for federated SIEM (`b.audit`, `b.audit.export({ format: "cadf" })`)
|
|
174
|
+
- **Metrics + tracing** — `b.metrics`, `b.tracing` (OTel pass-through); OTLP/HTTP-JSON exporter for traces + metrics (`b.otelExport`)
|
|
175
|
+
- **Log-stream sinks** — local file rotation, generic webhook, OTLP/HTTP-JSON OR OTLP/gRPC, AWS CloudWatch Logs via SigV4 with optional autoCreate, RFC 5424 syslog over UDP/TCP/TLS (`b.logStream`)
|
|
176
|
+
- **PII redaction** — `b.redact`
|
|
177
|
+
- **Decoy detection** — canary-credential / decoy-record framework auditing every positive lookup as `honeytoken.tripped` (`b.honeytoken`)
|
|
178
|
+
- **Boot assertions** — operator-callable security policy assertions (`b.security.assertProduction`); tamper-evident config-baseline drift detection signed with audit-signing key + at-boot vendor-bundle SHA-256 integrity verification across `lib/vendor/*` (`b.configDrift`, `b.configDrift.verifyVendorIntegrity`)
|
|
179
|
+
- **CSP reports + forensic export** — `b.middleware.cspReport`; post-incident audit-bundle composer (`b.auditTools.forensicSnapshot`)
|
|
180
|
+
|
|
181
|
+
### i18n + format helpers
|
|
182
|
+
|
|
183
|
+
- **i18n** — CLDR plural rules, Accept-Language negotiation, Intl formatters, RTL (`b.i18n`)
|
|
184
|
+
- **CSV** — RFC 4180 with Excel formula-injection prevention (`b.csv`)
|
|
185
|
+
- **IDs + slugs** — RFC 9562 UUID v4 + v7 (`b.uuid`); URL-safe slugs (`b.slug`)
|
|
186
|
+
- **Time + archive** — TZ-aware datetime (`b.time`); ZIP creation (`b.archive`)
|
|
187
|
+
- **Pagination + forms** — HMAC-signed cursor pagination (`b.pagination`); HTML form rendering + validation + CSRF (`b.forms`)
|
|
188
|
+
|
|
189
|
+
### Production
|
|
190
|
+
|
|
191
|
+
- **Cluster + scheduling** — cluster leader election with fenced leases over Postgres/SQLite (`b.cluster`); cron + interval scheduler that runs exactly-once globally (`b.scheduler`)
|
|
192
|
+
- **Reliability** — retry with full-jitter backoff + circuit breaker (`b.retry`); graceful shutdown (`b.appShutdown`); NTP boot check (`b.ntpCheck`)
|
|
193
|
+
- **Transactional integration** — outbox + dedupe-on-receive inbox; exactly-once semantics across Postgres / SQLite (`b.outbox`, `b.inbox`); Debezium-shape change-event envelope on the outbox (`b.outbox.create({ envelope: "debezium" })`)
|
|
194
|
+
- **Backup + restore** — end-to-end-encrypted bundles with pre-flush fail-closed mode + ML-DSA-87 signed manifests + scheduled backup-restore drills (`b.backup`, `b.backup.scheduleTest`, `b.backupBundle.verifyManifestSignature`); restore with pulled-bundle footprint preflight (`b.restore`); disaster-recovery runbook generator (HIPAA / PCI-DSS / GDPR / SOC 2 / DORA postures) (`b.drRunbook`)
|
|
195
|
+
- **Multi-tenant** — per-tenant DB storage caps, query budgets, tenant-isolation breach detection (`b.tenantQuota`); per-Postgres-role hardening with `pg_roles` enumeration guard (`b.externalDb.assertRoleHardening`)
|
|
196
|
+
- **Data export** — RFC 4180 strict CSV table export with SHA3-512 manifest + ML-DSA-87 signature + JSON Schema 2020-12 reflective metadata (`b.db.exportCsv`, `b.db.getTableMetadata`)
|
|
197
|
+
- **Retention** — GDPR / PCI / HIPAA-shaped rules with multi-stage warn → archive → erase, legal-hold exemptions, dry-run preview, cross-table cascade (`b.retention`)
|
|
198
|
+
- **Feature flags** — OpenFeature-spec client with pluggable providers + evaluation-context targeting + per-request `req.flag` accessor (`b.flag`)
|
|
199
|
+
- **Concurrency + kill-switches** — per-resource lock with cooperative-cancel + audit (`b.resourceAccessLock`); composite account-takeover kill-switch (`b.atoKillSwitch`)
|
|
200
|
+
- **Sandbox + spawn** — `worker_threads` sandbox with strict resource limits (`b.sandbox`, composable into `b.template.create({ sandbox: true })`); hardened `processSpawn` refusing shell-string invocation (`b.processSpawn`)
|
|
201
|
+
- **Egress allowlist** — per-host outbound destination allowlist (wildcard / per-method) via `b.httpClient.request({ allowedHosts: [...] })`
|
|
57
202
|
|
|
58
203
|
## Documentation
|
|
59
204
|
|
package/index.js
CHANGED
|
@@ -141,6 +141,7 @@ var ddlChangeControl = require("./lib/ddl-change-control");
|
|
|
141
141
|
var compliance = Object.assign({}, require("./lib/compliance"), {
|
|
142
142
|
eaa: require("./lib/compliance-eaa"),
|
|
143
143
|
});
|
|
144
|
+
var dataAct = require("./lib/data-act");
|
|
144
145
|
var gateContract = require("./lib/gate-contract");
|
|
145
146
|
var guardCsv = require("./lib/guard-csv");
|
|
146
147
|
var guardHtml = require("./lib/guard-html");
|
|
@@ -366,6 +367,7 @@ module.exports = {
|
|
|
366
367
|
auditDailyReview: auditDailyReview,
|
|
367
368
|
ddlChangeControl: ddlChangeControl,
|
|
368
369
|
compliance: compliance,
|
|
370
|
+
dataAct: dataAct,
|
|
369
371
|
gateContract: gateContract,
|
|
370
372
|
guardCsv: guardCsv,
|
|
371
373
|
guardHtml: guardHtml,
|
package/lib/audit.js
CHANGED
|
@@ -293,6 +293,7 @@ var FRAMEWORK_NAMESPACES = [
|
|
|
293
293
|
"mailarf", // b.mailArf (mailarf.parsed / mailarf.malformed — RFC 5965 abuse-feedback ingestion)
|
|
294
294
|
"mailbimi", // b.mail.bimi (mail.bimi.vmc.fetched / verified — RFC 9091 VMC chain validation)
|
|
295
295
|
"localdb", // b.localDb.thin (localdb.thin.opened / recovered / closed — desktop-daemon SQLite wrapper)
|
|
296
|
+
"dataact", // b.dataAct (EU Data Act 2023/2854 — product_declared / user_access / share_with_third_party / share_refused / switch_request)
|
|
296
297
|
];
|
|
297
298
|
var registeredNamespaces = new Set(FRAMEWORK_NAMESPACES);
|
|
298
299
|
|
package/lib/auth/oauth.js
CHANGED
|
@@ -543,11 +543,35 @@ function create(opts) {
|
|
|
543
543
|
return await _normalizeTokens(tokens, { nonce: eopts.nonce, skipNonceCheck: eopts.skipNonceCheck });
|
|
544
544
|
}
|
|
545
545
|
|
|
546
|
-
async function refreshAccessToken(refreshToken) {
|
|
546
|
+
async function refreshAccessToken(refreshToken, ropts) {
|
|
547
|
+
ropts = ropts || {};
|
|
547
548
|
if (!refreshToken) {
|
|
548
549
|
throw new OAuthError("auth-oauth/no-refresh-token",
|
|
549
550
|
"refreshAccessToken: refresh token is required");
|
|
550
551
|
}
|
|
552
|
+
// OAuth 2.1 §6.1 / RFC 9700 §4.13 — refresh-token replay defense.
|
|
553
|
+
// Operator passes a `seen(refreshToken)` callback that returns
|
|
554
|
+
// truthy when the SAME refresh_token has been presented before.
|
|
555
|
+
// The framework refuses the request loudly because OAuth 2.1
|
|
556
|
+
// mandates one-time-use refresh tokens for public + non-sender-
|
|
557
|
+
// constrained confidential clients. Operators with sender-
|
|
558
|
+
// constrained tokens (DPoP / mTLS) can opt out by NOT supplying
|
|
559
|
+
// a seen callback.
|
|
560
|
+
if (typeof ropts.seen === "function") {
|
|
561
|
+
var alreadySeen;
|
|
562
|
+
try { alreadySeen = await ropts.seen(refreshToken); }
|
|
563
|
+
catch (e) {
|
|
564
|
+
throw new OAuthError("auth-oauth/seen-callback-failed",
|
|
565
|
+
"refreshAccessToken: seen() callback threw: " + ((e && e.message) || String(e)));
|
|
566
|
+
}
|
|
567
|
+
if (alreadySeen === true) {
|
|
568
|
+
throw new OAuthError("auth-oauth/refresh-token-replay",
|
|
569
|
+
"refreshAccessToken: refresh token has been presented before — refused " +
|
|
570
|
+
"(OAuth 2.1 §6.1 / RFC 9700 §4.13 one-time-use defense). The operator MUST " +
|
|
571
|
+
"treat this as a token-theft signal: revoke the refresh-token family + force " +
|
|
572
|
+
"the user to re-authenticate.");
|
|
573
|
+
}
|
|
574
|
+
}
|
|
551
575
|
var endpoint = await _resolveEndpoint("tokenEndpoint");
|
|
552
576
|
var body = new URLSearchParams();
|
|
553
577
|
body.set("grant_type", "refresh_token");
|
|
@@ -556,8 +580,181 @@ function create(opts) {
|
|
|
556
580
|
if (clientSecret) body.set("client_secret", clientSecret);
|
|
557
581
|
var tokens = await _postForm(endpoint, body);
|
|
558
582
|
// Refreshed tokens may not include a new id_token; verification
|
|
559
|
-
// is conditional.
|
|
560
|
-
|
|
583
|
+
// is conditional. We surface rotation explicitly so the operator's
|
|
584
|
+
// store can swap the old refresh_token for the new one and feed
|
|
585
|
+
// the new one to the next seen() check.
|
|
586
|
+
var normalized = await _normalizeTokens(tokens, { skipNonceCheck: true });
|
|
587
|
+
if (normalized.refreshToken && normalized.refreshToken !== refreshToken) {
|
|
588
|
+
normalized.refreshTokenRotated = true;
|
|
589
|
+
normalized.previousRefreshToken = refreshToken;
|
|
590
|
+
} else {
|
|
591
|
+
normalized.refreshTokenRotated = false;
|
|
592
|
+
}
|
|
593
|
+
return normalized;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* @primitive b.auth.oauth.parseCallback
|
|
598
|
+
* @signature b.auth.oauth.parseCallback(query, opts?)
|
|
599
|
+
* @since 0.8.70
|
|
600
|
+
* @related b.auth.oauth.parseJarmResponse, b.fapi2.assertCallback
|
|
601
|
+
*
|
|
602
|
+
* Parses the OP's redirect-back query/form parameters and applies
|
|
603
|
+
* RFC 9207 OAuth 2.0 Authorization Server Issuer Identification
|
|
604
|
+
* cross-checks. The `iss` parameter the OP echoes on the callback
|
|
605
|
+
* MUST match the configured issuer; mismatches surface as a
|
|
606
|
+
* deterministic refusal (mix-up / IdP-substitution defense per
|
|
607
|
+
* RFC 9207 §2.3).
|
|
608
|
+
*
|
|
609
|
+
* The framework refuses the callback when:
|
|
610
|
+
* - an `error` param is present (OP-side authorization failure)
|
|
611
|
+
* - `iss` is present but does NOT match the configured issuer
|
|
612
|
+
* - `state` is supplied to opts.expectedState and doesn't match
|
|
613
|
+
*
|
|
614
|
+
* Returns `{ code, state, iss }` for the happy path. Operators feed
|
|
615
|
+
* `code` + their stored `verifier` + `nonce` to `exchangeCode`.
|
|
616
|
+
*
|
|
617
|
+
* The OP advertises support via `authorization_response_iss_parameter_supported`
|
|
618
|
+
* in discovery; the framework reads it once at the first parseCallback
|
|
619
|
+
* call and refuses missing-`iss` callbacks under FAPI 2.0 posture
|
|
620
|
+
* regardless (per FAPI 2.0 §5.4.2).
|
|
621
|
+
*
|
|
622
|
+
* @opts
|
|
623
|
+
* {
|
|
624
|
+
* expectedState?: string, // value returned by authorizationUrl()
|
|
625
|
+
* requireIssParam?: boolean, // refuse callbacks lacking iss (default: read OP discovery; FAPI 2.0 forces true)
|
|
626
|
+
* }
|
|
627
|
+
*
|
|
628
|
+
* @example
|
|
629
|
+
* app.get("/oauth/callback", async function (req, res) {
|
|
630
|
+
* var url = new URL(req.url, "http://placeholder.invalid");
|
|
631
|
+
* var params = Object.fromEntries(url.searchParams);
|
|
632
|
+
* var parsed = await oauth.parseCallback(params, { expectedState: req.session.oauthState });
|
|
633
|
+
* var tokens = await oauth.exchangeCode({ code: parsed.code,
|
|
634
|
+
* verifier: req.session.pkceVerifier, nonce: req.session.oidcNonce });
|
|
635
|
+
* });
|
|
636
|
+
*/
|
|
637
|
+
async function parseCallback(query, popts) {
|
|
638
|
+
popts = popts || {};
|
|
639
|
+
if (!query || typeof query !== "object") {
|
|
640
|
+
throw new OAuthError("auth-oauth/bad-callback",
|
|
641
|
+
"parseCallback: query must be an object of param key→value");
|
|
642
|
+
}
|
|
643
|
+
if (typeof query.error === "string" && query.error.length > 0) {
|
|
644
|
+
var aerr = new OAuthError("auth-oauth/op-error",
|
|
645
|
+
"parseCallback: OP returned error '" + query.error + "'" +
|
|
646
|
+
(query.error_description ? ": " + query.error_description : ""));
|
|
647
|
+
aerr.opError = query.error;
|
|
648
|
+
aerr.opErrorDescription = query.error_description || null;
|
|
649
|
+
throw aerr;
|
|
650
|
+
}
|
|
651
|
+
// RFC 9207 — when the OP echoes `iss`, cross-check it against the
|
|
652
|
+
// configured issuer. Defends against the mix-up attack where an
|
|
653
|
+
// honest-but-curious OP receives a code intended for a different
|
|
654
|
+
// OP. The cross-check is critical for OPs with multi-tenant
|
|
655
|
+
// shared clients.
|
|
656
|
+
var requireIss = popts.requireIssParam === true;
|
|
657
|
+
if (!requireIss) {
|
|
658
|
+
// OP discovery may advertise support; check once.
|
|
659
|
+
var disc = null;
|
|
660
|
+
try { disc = await _discover(); } catch (_e) { /* discovery already failed elsewhere; let exchangeCode surface it */ }
|
|
661
|
+
if (disc && disc.authorization_response_iss_parameter_supported === true) {
|
|
662
|
+
requireIss = true;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
if (typeof query.iss === "string" && query.iss.length > 0) {
|
|
666
|
+
if (query.iss !== issuer) {
|
|
667
|
+
throw new OAuthError("auth-oauth/iss-mismatch-callback",
|
|
668
|
+
"parseCallback: callback iss '" + query.iss + "' does not match " +
|
|
669
|
+
"configured issuer '" + issuer + "' (RFC 9207 §2.3 mix-up defense)");
|
|
670
|
+
}
|
|
671
|
+
} else if (requireIss) {
|
|
672
|
+
throw new OAuthError("auth-oauth/missing-iss-callback",
|
|
673
|
+
"parseCallback: OP advertises authorization_response_iss_parameter_supported " +
|
|
674
|
+
"but the callback omitted `iss` — refused (RFC 9207 / FAPI 2.0 §5.4.2)");
|
|
675
|
+
}
|
|
676
|
+
if (popts.expectedState !== undefined && popts.expectedState !== null) {
|
|
677
|
+
if (query.state !== popts.expectedState) {
|
|
678
|
+
throw new OAuthError("auth-oauth/state-mismatch",
|
|
679
|
+
"parseCallback: state mismatch (CSRF defense). Expected '" +
|
|
680
|
+
popts.expectedState + "', got '" + query.state + "'");
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
if (typeof query.code !== "string" || query.code.length === 0) {
|
|
684
|
+
throw new OAuthError("auth-oauth/no-code-in-callback",
|
|
685
|
+
"parseCallback: callback missing `code` parameter");
|
|
686
|
+
}
|
|
687
|
+
return { code: query.code, state: query.state || null, iss: query.iss || issuer };
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* @primitive b.auth.oauth.parseJarmResponse
|
|
692
|
+
* @signature b.auth.oauth.parseJarmResponse(responseJwt, opts?)
|
|
693
|
+
* @since 0.8.70
|
|
694
|
+
* @related b.auth.oauth.parseCallback, b.fapi2.assertCallback
|
|
695
|
+
*
|
|
696
|
+
* JWT Authorization Response Mode (JARM, OAuth 2.0 JARM spec).
|
|
697
|
+
* When `response_mode` is `query.jwt` / `fragment.jwt` /
|
|
698
|
+
* `form_post.jwt`, the OP delivers the authorization response as a
|
|
699
|
+
* signed JWT in a single `response` parameter instead of as bare
|
|
700
|
+
* query/form params. This primitive verifies the JWS against the
|
|
701
|
+
* OP's JWKS, validates `iss` / `aud` / `exp` / `nbf`, and returns
|
|
702
|
+
* the inner params (`code` / `state` / `iss` / `error`) as if they
|
|
703
|
+
* had been the raw query.
|
|
704
|
+
*
|
|
705
|
+
* The verified params then flow through `parseCallback` for the
|
|
706
|
+
* normal RFC 9207 + state-CSRF + error-refusal pipeline.
|
|
707
|
+
*
|
|
708
|
+
* @opts
|
|
709
|
+
* {
|
|
710
|
+
* expectedState?: string,
|
|
711
|
+
* acceptedAlgs?: string[], // default: framework's accepted set
|
|
712
|
+
* maxClockSkewMs?: number,
|
|
713
|
+
* }
|
|
714
|
+
*
|
|
715
|
+
* @example
|
|
716
|
+
* app.get("/oauth/callback", async function (req, res) {
|
|
717
|
+
* var jwt = new URL(req.url, "x:/").searchParams.get("response");
|
|
718
|
+
* var params = await oauth.parseJarmResponse(jwt, { expectedState: req.session.oauthState });
|
|
719
|
+
* var tokens = await oauth.exchangeCode({ code: params.code,
|
|
720
|
+
* verifier: req.session.pkceVerifier, nonce: req.session.oidcNonce });
|
|
721
|
+
* });
|
|
722
|
+
*/
|
|
723
|
+
async function parseJarmResponse(responseJwt, jopts) {
|
|
724
|
+
jopts = jopts || {};
|
|
725
|
+
if (typeof responseJwt !== "string" || responseJwt.length === 0) {
|
|
726
|
+
throw new OAuthError("auth-oauth/no-jarm-response",
|
|
727
|
+
"parseJarmResponse: response JWT required");
|
|
728
|
+
}
|
|
729
|
+
if (responseJwt.split(".").length !== 3) {
|
|
730
|
+
throw new OAuthError("auth-oauth/malformed-jarm-response",
|
|
731
|
+
"parseJarmResponse: response is not a 3-segment JWS");
|
|
732
|
+
}
|
|
733
|
+
// Reuse verifyIdToken's JWKS-lookup + signature path. JARM
|
|
734
|
+
// responses share the OP's signing keypair; the checks differ
|
|
735
|
+
// only in claim validation (no nonce, audience = clientId, no
|
|
736
|
+
// ID-token-specific claims). We wrap verifyIdToken with the
|
|
737
|
+
// skip-nonce flag and apply JARM-specific claim checks below.
|
|
738
|
+
var verified = await verifyIdToken(responseJwt, {
|
|
739
|
+
skipNonceCheck: true,
|
|
740
|
+
acceptedAlgs: jopts.acceptedAlgs,
|
|
741
|
+
maxClockSkewMs: jopts.maxClockSkewMs,
|
|
742
|
+
});
|
|
743
|
+
var c = verified.claims;
|
|
744
|
+
// Per JARM §4: `iss` MUST match the OP issuer; `aud` MUST contain
|
|
745
|
+
// the client_id; `exp` enforced (verifyIdToken already does);
|
|
746
|
+
// `nonce` MUST NOT be present (JARM responses are not ID tokens).
|
|
747
|
+
if (Object.prototype.hasOwnProperty.call(c, "nonce")) {
|
|
748
|
+
throw new OAuthError("auth-oauth/jarm-forbidden-nonce",
|
|
749
|
+
"parseJarmResponse: JARM responses MUST NOT carry `nonce` (JARM §4)");
|
|
750
|
+
}
|
|
751
|
+
return await parseCallback({
|
|
752
|
+
code: c.code,
|
|
753
|
+
state: c.state,
|
|
754
|
+
iss: c.iss,
|
|
755
|
+
error: c.error,
|
|
756
|
+
error_description: c.error_description,
|
|
757
|
+
}, { expectedState: jopts.expectedState, requireIssParam: jopts.requireIssParam });
|
|
561
758
|
}
|
|
562
759
|
|
|
563
760
|
// OIDC requires fetchUserInfo to be called AFTER the id_token has
|
|
@@ -1068,6 +1265,8 @@ function create(opts) {
|
|
|
1068
1265
|
parseFrontchannelLogoutRequest: parseFrontchannelLogoutRequest,
|
|
1069
1266
|
verifyBackchannelLogoutToken: verifyBackchannelLogoutToken,
|
|
1070
1267
|
checkSessionIframeUrl: checkSessionIframeUrl,
|
|
1268
|
+
parseCallback: parseCallback,
|
|
1269
|
+
parseJarmResponse: parseJarmResponse,
|
|
1071
1270
|
// Diagnostic / power-user surface
|
|
1072
1271
|
issuer: issuer,
|
|
1073
1272
|
clientId: clientId,
|