@blamejs/core 0.8.59 → 0.8.64
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/README.md +2 -2
- package/index.js +11 -0
- package/lib/audit.js +1 -0
- package/lib/auth/ciba.js +530 -0
- package/lib/auth/oauth.js +199 -11
- package/lib/auth/oid4vci.js +588 -0
- package/lib/auth/oid4vp.js +514 -0
- package/lib/auth/openid-federation.js +523 -0
- package/lib/auth/saml.js +636 -0
- package/lib/auth/sd-jwt-vc-holder.js +30 -8
- package/lib/auth/sd-jwt-vc.js +61 -7
- package/lib/db-collection.js +402 -105
- package/lib/db-file-lifecycle.js +333 -0
- package/lib/session-stores.js +138 -0
- package/lib/session.js +307 -20
- package/lib/validate-opts.js +41 -0
- package/lib/xml-c14n.js +499 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,11 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.8.x
|
|
10
10
|
|
|
11
|
+
- v0.8.64 (2026-05-10) — CI green-up for v0.8.63 (wiki source-comment validator). The v0.8.62 push missed `examples/wiki/test/validate-source-comment-blocks.js` — a wiki-side validator that enforces every `@primitive` block has `@signature` starting with `b.`, matching function arity, an `@opts` declaration when the function takes opts, an `@example` block, and resolvable `@related` cross-refs. 50 findings across the new federation/VC primitives. Fixed: every nested-namespace `@signature` (`b.auth.ciba.client.X`, `b.auth.oid4vci.issuer.X`, `b.auth.oid4vp.verifier.X`, `b.auth.saml.sp.X`) re-qualified with the full `b.*` prefix instead of the bare `client.X` shorthand; arity collapsed to `(opts)` where the function takes a single opts arg; `@example` block added to every primitive; `@opts` block added to ciba.client.startAuthentication / parseNotification + openidFederation.resolveLeaf; `@primitive` block added to xmlC14n.parse; `@related` cross-refs scrubbed of dangling references to undocumented primitives (oauth.create / sdJwtVc.issuer aren't @primitive-documented yet); the parse-as-JS check on oid4vci's @example caught a `{...}` literal with no target — replaced with a concrete object spread. No primitive surface change versus v0.8.63.
|
|
12
|
+
- v0.8.63 (2026-05-10) — CI green-up for v0.8.62. The v0.8.62 npm-publish workflow's lint gate flagged ten ESLint findings the local pre-ship audit missed (eslint not run before commit): unused vars (`openTag` in xml-c14n; `cache` + unused `C` import + `DEFAULT_CHAIN_TTL_MS` in openid-federation; `safeJson` in oid4vp; `sdJwtVcCore` + `SUPPORTED_PROOF_TYPES` in oid4vci), an unnecessary `\-` escape in xml-c14n's name-character regex, and a control-character regex in ciba's binding_message validator (eslint's `no-control-regex` refuses control-char ranges in regex literals regardless of `\u` escaping). Fixed by removing the dead vars + dead escape, and replacing the regex with an explicit codepoint scan in `_validateBindingMessage` that walks `msg.charCodeAt(i)` and refuses C0 / DEL+C1 / zero-width / bidi-mark / bidi-isolate / BOM ranges. No primitive surface change versus v0.8.62.
|
|
13
|
+
- v0.8.62 (2026-05-10) — federation / VC primitive family + standalone DB-file lifecycle + anonymous-session ergonomics. **OpenID Connect Front-Channel + Back-Channel Logout 1.0** on `b.auth.oauth`: `parseFrontchannelLogoutRequest(req)` validates iss + sid query params; `verifyBackchannelLogoutToken(jwt, vopts)` verifies the JWS (typ=logout+jwt), validates the events claim per OIDC §2.6, refuses logout-tokens carrying nonce, requires sub OR sid, and runs an operator-supplied `seen({jti,iss,iat})` callback for jti-replay defense. Discovery surface extended to `check_session_iframe` + `backchannel_authentication_endpoint`. **CIBA Core 1.0** at `b.auth.ciba.client.create({ deliveryMode: "poll"|"ping"|"push", ... })` with `startAuthentication` / `pollToken` / `parseNotification`; supports JWT-bearer / mTLS / shared-secret client auth, binding_message + acr_values + requested_expiry, ping/push notification token (timing-safe compared via sha3 hash), and the urn:openid:params:grant-type:ciba grant. **OpenID4VCI 1.0** at `b.auth.oid4vci.issuer.create({ sdJwtIssuer, supportedCredentials, ... })` — issuer-initiated credential_offer with pre-authorized_code + tx_code, /token grant exchange, /credential endpoint with proof-JWT verification (typ=openid4vci-proof+jwt, iat freshness, nonce-replay defense, holder-key binding via header.jwk + JWS verify), c_nonce rotation per request, /.well-known/openid-credential-issuer metadata. Composes `b.auth.sdJwtVc.issuer` for SD-JWT VC minting. **OpenID4VP 1.0 + DCQL** at `b.auth.oid4vp.verifier.create({ ... })` — `createRequest` builds a vp_token authz request with a DCQL query; `verifyResponse` parses the wallet's vp_token, runs each presentation through `b.auth.sdJwtVc.verify` (audience + nonce + KB-JWT bound, optional key_attestation verifier), then runs the DCQL matcher. DCQL implementation covers credentials[] (id / format / meta vct_values / meta issuer_values / claims with path + values) and credential_sets[] (options + required) per OID4VP §6. **OpenID Federation 1.0** at `b.auth.openidFederation.{parseEntityStatement, verifyEntityStatement, buildTrustChain, applyMetadataPolicy, resolveLeaf}` — fetches entity configs from `<entity>/.well-known/openid-federation`, walks `authority_hints` up to a trust anchor (operator-pinned JWKS), verifies each subordinate-statement JWS, and applies the federation's metadata_policy (value / default / add / one_of / subset_of / superset_of / essential — unknown operators refuse). **SAML 2.0 SP** at `b.auth.saml.sp.create({ entityId, idpEntityId, idpCertPem, ... })` — AuthnRequest builder for HTTP-Redirect/POST bindings, Response parser that verifies XMLDSig (Response-level OR Assertion-level signature), defends against signature-wrapping via `b.xmlC14n.canonicalizeElementById`'s single-match invariant, validates SubjectConfirmation Bearer (NotOnOrAfter / NotBefore / Recipient / InResponseTo) + Conditions audience + Status. Plus `b.auth.saml.fetchMdq({ baseUrl, entityId, trustCertPem? })` for MDQ-style metadata fetch with optional XMLDSig verification. **`b.xmlC14n`** — RFC 3741 Exclusive XML Canonicalization 1.0 (SAML/XMLDSig subset): `canonicalize(input, opts?)` and `canonicalizeElementById(xml, id, opts?)`. The `canonicalizeElementById` single-match invariant is the core defense against XML signature-wrapping attacks (refuses ID collisions + zero-match references). Doctype + ENTITY refused at parse time. **SD-JWT VC `key_attestation` extension** — `b.auth.sdJwtVc.holder.store({..., keyAttestation })` persists a holder-side attestation JWT alongside the credential; `b.auth.sdJwtVc.present({..., keyAttestation })` embeds it in the KB-JWT header; `b.auth.sdJwtVc.verify(presentation, { keyAttestationVerifier, requireKeyAttestation })` surfaces the attestation token to an operator-supplied verifier so trust-anchor decisions (TEE / FIDO MDS3 / App Attest / Play Integrity) stay operator-side. **`b.db.fileLifecycle({ dataDir, vault, ... })`** — standalone encrypted-DB-file lifecycle for consumers that own their own SQLite handle (own schema, own migrations, own connection). Decrypts `<dataDir>/db.enc` to a tmpfs path (`/dev/shm` on Linux), exposes `dbPath` for the operator to open, runs a periodic re-encrypt flush via `startFlushTimer(db)`, returns an in-memory snapshot via `snapshot(db)`, and runs a graceful flush + cleanup via `flushAndCleanup(db, opts)`. Same envelope shape as `b.db`; no schema / audit-chain coupling. **`b.session.create({ anonymous: true })`** auto-mints `userId = "anon:" + crypto.randomUUID()` so operators running pre-login flows keep the full sealed-cookie + sealed-userId + sidHash + idle/absolute-timeout posture without rolling their own opaque-id pattern. `b.session.isAnonymous(userId)` helper for post-auth gates. `destroyAllForUser` refuses anon-prefix ids (per-session, not portable). **`validateOpts.makeNamespacedEmitters(prefix, { audit, observability })`** — collapses the recurring per-primitive `_emitAudit / _emitMetric` boilerplate into one call; new federation/VC primitives consume it. Wiki e2e regex updated for the v0.8.61 sealed-cookie format (`vault:<base64>` instead of pre-v0.8.61 hex).
|
|
14
|
+
- v0.8.61 (2026-05-10) — `b.db.collection` schemaless-document opts, `b.session` PQC-sealed cookie default, `/24`+`/64` IP-prefix fingerprint binding, pluggable session store. **Operator-visible breaking change**: `b.session.create(...).token` now returns a vault-sealed envelope (`vault:` prefix, ML-KEM-1024 + P-384 hybrid + XChaCha20-Poly1305) instead of the plaintext sid. Pre-v0.8.61 raw-sid cookies fail to unseal at `b.session.verify` and the affected user re-authenticates. Pre-v1.0 ships no compat shim — every existing session force-logs-out on upgrade. Storage path unchanged: the DB still keys on `sha3('bj-session:' || sid)`, sealing only changes the wire format. **db.collection schemaless extensions**: pass `{ overflow: "data" }` to fold every insert/update field outside the table's column list into a JSON-text column; `find` / `findOne` parse it and merge keys back onto the row. `WHERE` on an unknown field rewrites to `JSON_EXTRACT(<overflow>, '$.field')` for `$eq` / `$ne` / `$in` (range / `$like` require a real column with an index). Pass `{ jsonColumns: ["roles", "metadata"] }` to auto-`JSON.stringify` listed columns on write and parse them via `b.safeJson` on read; unknown columns refuse at first use. Pass `{ sealedFields: { email: "emailHash" } }` to co-locate the sealed-column / derived-hash declaration with the collection — registers via `b.cryptoField.registerTable` so the existing query-builder sealed-field rewrite (`where({ email: "x" })` → `where({ emailHash: <hash> })`) picks up automatically. **Session fingerprint subnet binding**: `fingerprintFields: ["clientIpPrefix"]` hashes the client IP at `/24` for IPv4 and `/64` for IPv6 (per RFC 4291 §2.5.4 customer-LAN allocation), so roaming carriers' per-request IP flips no longer log out healthy mobile users. IPv4-mapped IPv6 (`::ffff:1.2.3.4`) buckets as v4 `/24`. **Pluggable session store**: `b.session.useStore(store)` swaps the `_blamejs_sessions` storage backend; first-party adapter `b.session.stores.localDbThin({ file })` wraps `b.localDb.thin` (typically pointed at tmpfs) so heavy session churn doesn't fight the main DB's WAL fsync + at-rest re-encryption cycle. `audit.FRAMEWORK_NAMESPACES` extended with `localdb` so `b.localDb.thin` open / close events stop dropping. New per-primitive Layer 0 tests: `db-collection-extensions.test.js` (29 checks), `session-extensions.test.js` (24 checks).
|
|
15
|
+
- v0.8.60 (2026-05-09) — CI green-up for v0.8.59. macOS smoke runner failed `watcher.test.js: surface onChange for non-ignored file` again under SMOKE_PARALLEL=64 + GitHub-Actions-runner contention; the v0.8.56 bump from 300ms→1500ms wasn't enough on a contended Darwin runner. Replaced the fixed-budget wait with two structural fixes: (a) a 200ms priming wait BEFORE the test writes any files, so the FSEvents watcher is fully established before file-system activity races its startup; (b) a poll-until-event loop after writes that flushes + checks the change set every 100ms up to a 5s deadline, exiting early when the target event lands. Linux/Windows still finish in <100ms; the wider budget only matters on contended macOS. No primitive surface change versus v0.8.59.
|
|
11
16
|
- v0.8.59 (2026-05-09) — comment cleanup. Two stale references to an external consumer name inside `lib/db-collection.js` and `lib/template.js` comments stripped — names of downstream consumers don't belong in framework source. No primitive surface change versus v0.8.58.
|
|
12
17
|
- v0.8.58 (2026-05-09) — `b.db` query-builder + facade extensions, db.init opt-outs, snapshot helper, mtls path fix. **Query atoms** on `b.db.from(table)`: `.increment(column, delta)` (atomic `UPDATE col = COALESCE(col, 0) + ?`, refuses unconditional, returns rows-changed), `.whereGroup(qb => ...)` (closure-form OR composition via new `WhereBuilder` class with `.eq` / `.neq` / `.gt` / `.gte` / `.lt` / `.lte` / `.in` / `.like` AND vs `.orEq` / `.orNeq` / ... OR + `.raw`), `.orWhere(...)` (top-level OR; accepts object map / `(field, value)` / `(field, op, value)` / closure), `.search(fields, term, opts?)` (chainable LIKE-OR with `match: "substring"|"prefix"|"exact"`, `~` ESCAPE char to safely handle user-supplied `%`/`_`), `.paginate(opts)` (returns `{ items, total, limit, offset, page, totalPages }`; default limit 25, cap 1000). **Mongo facade** at `b.db.collection(name)` returning `{ insert, insertMany, find, findOne, update, updateMany, remove, count, paginate }` — maps Mongo-shape calls onto `b.db.from(name)`. Update operators: `$set` / `$inc` (composes `Query.increment`) / `$unset`. Query operators: `$eq` / `$ne` / `$gt` / `$gte` / `$lt` / `$lte` / `$in` / `$like`. Unknown operators throw at config-time. **db.init opt-outs**: `frameworkTables: false` skips provisioning `audit_log` / `consent_log` (operators with their own audit chain reuse the framework's `b.db` / `b.vault` / `b.cryptoField` primitives without the bundled chain tables — append-only triggers, chain verifies, WORM assertions, audit-signing bootstrap, checkpoint verifies, and the `audit_log` / `consent_log` reserved-name refusal all become no-ops). `auditSigning: false` (finer-grained — keep framework tables but skip the audit-signing-key bootstrap when the host manages its own signing key). **db.init path overrides**: `encryptedDbPath` (fully-qualified path to `db.enc`), `encryptedDbName` (basename under `dataDir`, default `"db.enc"`), `dbKeyPath` (encryption-key file outside `dataDir`, e.g. KMS-fronted volume). **`b.db.snapshot()`** — in-memory encrypted Buffer (same envelope `flushToDisk` writes, just held in memory; WAL checkpoint forced first so committed state captures cleanly). Plain mode returns the raw plaintext SQLite file. **`b.mtlsCa.create({ paths })` absolute-path fix** — `_resolvePaths` no longer joins absolute path entries under `dataDir`. The pre-v0.8.58 shape silently rewrote `MTLS_CA_KEY=/etc/ssl/ca.key` → `<dataDir>/etc/ssl/ca.key`, breaking operators with externally-mounted CA files. Standard `path.join` semantics already preserve absolute arguments — the always-join was an oversight. Relative entries still join under `dataDir` (back-compat). **CLAUDE.md release workflow §5** rewritten — host smoke + host wiki e2e run BEFORE container smoke + container wiki e2e, sequentially, never in parallel. Both runners write to `.test-output/smoke.log` and `.test-output/wiki-e2e.log`; parallel runs clobber each other so the log of the actually-blocking failure may be overwritten by whichever leg finishes second. The container leg writes to `smoke-container.log` / `wiki-e2e-container.log` so diagnose-after-failure is one file lookup. New per-primitive Layer 0 tests: `db-query-extensions.test.js` (27 checks), `db-collection.test.js` (23 checks), `db-init-extensions.test.js` (13 checks), `mtls-ca-paths.test.js` (7 checks). **Deferred**: configurable framework-schema column names — the original proposal was incomplete; re-open with the full spec. Configurable framework-table names (rename `audit_log` / `consent_log`) — `frameworkTables: false` covers the audit-conflict use case; the full rename touches dozens of hardcoded SQL literals across the audit + consent modules and is its own patch with a refactor proper.
|
|
13
18
|
- v0.8.57 (2026-05-09) — CI green-up for v0.8.56. The v0.8.56 npm-publish workflow's smoke gate passed but the `prepack-guard` script (`scripts/check-pack-against-gitignore.js`) rejected the tarball: it ran `git check-ignore -v` against every packed path and treated EVERY matching gitignore line as "ignored", including `!`-prefixed negation rules. The newly-tracked `lib/vendor/bimi-trust-anchors.pem` matches the `!lib/vendor/*.pem` negation rule that v0.8.54 added, but the script flagged it as "gitignored in tarball" and exited 1. Fix: filter out lines whose matching pattern starts with `!` — those are negation rules indicating the file is NOT actually ignored. No primitive surface change versus v0.8.56.
|
package/README.md
CHANGED
|
@@ -41,8 +41,8 @@ var b = require("@blamejs/core");
|
|
|
41
41
|
|
|
42
42
|
The framework bundles the surface a typical Node app reaches for. Every primitive listed is callable today; nothing is a stub.
|
|
43
43
|
|
|
44
|
-
- **Data layer** — SQLite with sealed-by-default columns (`b.db`), migrations, seeders, atomic-file writes; bring-your-own external Postgres / MySQL / etc. 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`); S3 / R2 / B2 / GCS / Azure object store with multipart upload + SSE + bucket-ops (create / delete / list / lifecycle / CORS) across all three clouds, plus S3 Object Lock + per-object retention + legal hold for write-once-read-many compliance workloads (`b.storage`, `b.objectStore`); durable queue with priority + cron + flows on the local SQLite backend, a shared Redis backend, OR AWS SQS via SigV4 + AWSJsonProtocol_1.0 for fully-managed multi-replica deploys (`b.queue`, `b.jobs`); cluster-shared cache (`b.cache`).
|
|
45
|
-
- **Identity & access** — passwords (Argon2id) + policy primitive (NIST 800-63B / PCI-DSS 4.0 / HIPAA-AAL2 profiles, HaveIBeenPwned k-anonymity breach check, length / context / dictionary / complexity rules, rotation + history) (`b.auth.password`); passkeys (WebAuthn), TOTP, JWT (PQ-default), OAuth,
|
|
44
|
+
- **Data layer** — SQLite with sealed-by-default columns (`b.db`), migrations, seeders, atomic-file writes; chainable query builder with 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 }`; Mongo-style document-store facade `b.db.collection(name, opts?)` (`$set` / `$inc` / `$unset` / `$eq` / `$ne` / `$gt` / `$gte` / `$lt` / `$lte` / `$in` / `$like`) with schemaless-document opts — `overflow: "<col>"` folds unknown insert/update fields into a JSON-text column (and rewrites `WHERE` on virtual fields to `JSON_EXTRACT`), `jsonColumns: [...]` auto-stringifies on write + parses via `b.safeJson` on read, `sealedFields: { email: "emailHash" }` co-locates a `b.cryptoField` sealed-column / derived-hash declaration with the collection so plaintext lookups auto-rewrite to hash-column lookups; in-memory encrypted snapshot via `b.db.snapshot()`; standalone encrypted-DB-file lifecycle for consumers that own their own SQLite handle (`b.db.fileLifecycle({ dataDir, vault })` — decrypt-to-tmpfs, periodic re-encrypt flush, snapshot, graceful shutdown — same envelope as `b.db`, no schema/audit-chain coupling); `db.init` opt-outs for hosts that own their audit chain (`frameworkTables: false` / `auditSigning: false`) and configurable encrypted-file paths (`encryptedDbPath` / `encryptedDbName` / `dbKeyPath`); bring-your-own external Postgres / MySQL / etc. 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`); S3 / R2 / B2 / GCS / Azure object store with multipart upload + SSE + bucket-ops (create / delete / list / lifecycle / CORS) across all three clouds, plus S3 Object Lock + per-object retention + legal hold for write-once-read-many compliance workloads (`b.storage`, `b.objectStore`); durable queue with priority + cron + flows on the local SQLite backend, a shared Redis backend, OR AWS SQS via SigV4 + AWSJsonProtocol_1.0 for fully-managed multi-replica deploys (`b.queue`, `b.jobs`); cluster-shared cache (`b.cache`).
|
|
45
|
+
- **Identity & access** — passwords (Argon2id) + policy primitive (NIST 800-63B / PCI-DSS 4.0 / HIPAA-AAL2 profiles, HaveIBeenPwned k-anonymity breach check, length / context / dictionary / complexity rules, rotation + history) (`b.auth.password`); passkeys (WebAuthn), TOTP, JWT (PQ-default), OAuth + OIDC RP-Initiated / Front-Channel / Back-Channel Logout 1.0 (`b.auth.oauth.parseFrontchannelLogoutRequest` + `verifyBackchannelLogoutToken` with jti-replay defense), CIBA Core 1.0 decoupled-auth client (`b.auth.ciba`, poll/ping/push delivery, JWT-bearer / mTLS / shared-secret client auth), OpenID Federation 1.0 trust chain (`b.auth.openidFederation`, entity statement parse + verify, metadata_policy resolution), SAML 2.0 SP with XMLDSig signature-wrapping defense via single-match-by-ID invariant + RFC 9525 server-identity (`b.auth.saml`), OpenID4VCI 1.0 issuer (`b.auth.oid4vci` — credential_offer / pre-authorized_code / /credential with proof-JWT verification), OpenID4VP 1.0 verifier with DCQL query language (`b.auth.oid4vp`), SD-JWT VC with optional `key_attestation` extension for TEE / FIDO MDS3 / App Attest holder-key provenance (`b.auth.sdJwtVc`), sessions with PQC-sealed sid cookie (ML-KEM-1024 + P-384 hybrid + XChaCha20-Poly1305 envelope on the wire), `/24` IPv4 + `/64` IPv6 subnet binding via `fingerprintFields: ["clientIpPrefix"]` (carrier-roaming-safe), pluggable storage backend via `b.session.useStore` + first-party `b.session.stores.localDbThin` (tmpfs-fast session writes that don't fight the main DB's encrypted-at-rest re-flush cycle), opaque-userId anonymous sessions via `b.session.create({ anonymous: true })`, idle / absolute timeouts, optional fingerprint drift detection + anomaly scoring, brute-force lockout (`b.auth.*`, `b.session`); RBAC + optional per-role DB binding + role-spec `requireMfa` + per-route MFA freshness window + ABAC predicate registry (`b.permissions`); API keys with rotation (`b.apiKey`); break-glass column gates with second-factor + audit (`b.breakGlass`); two-person-rule approval workflow with m-of-n quorum + cooling-off lock + approver-role gate + cancellation (`b.dualControl`); FAPI 2.0 Final composite posture (PAR + PKCE-S256 + DPoP-or-mTLS sender-constrained tokens + RFC 9207 issuer-in-callback) for financial / Open Banking deployments (`b.fapi2`); CFPB §1033 / FDX 6.0 consumer-financial-data-sharing wrapper (`b.fdx`); cross-table data-subject coordination (export / rectify / erase / restrict / objection) walking every table tagged with the subject's column without app-side plumbing (`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`); adaptive bot-challenge staircase composing `b.middleware.botGuard` + `b.auth.lockout` + operator challenge / escalation hooks for auth paths (`b.authBotChallenge`); session-to-device-posture binding (UA + Accept-Language + Accept-Encoding + IP /24 prefix + optional WebAuthn-bound key) with fail-closed verify on store error (`b.sessionDeviceBinding`).
|
|
46
46
|
- **Crypto** — envelope-versioned PQC at rest (ML-KEM-1024 + P-384 hybrid, XChaCha20-Poly1305, SHAKE256), vault sealing, field-level crypto + cryptographic erasure (`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 — true crypto-shred discipline (`b.cryptoField.declareColumnResidency`, `b.cryptoField.declarePerRowKey`); AAD-bound sealed columns whose AEAD tag is tied to a `(table, rowId, column, schemaVersion)` tuple so a copy-paste between rows or a schema-version replay surfaces as a refused decrypt (`b.vault.aad`); signed webhooks (SLH-DSA-SHAKE-256f default; ML-DSA-65 opt-in for smaller signatures + faster verify), ECIES API encryption (`b.crypto`, `b.vault`, `b.webhook`); RFC 9180 HPKE with ML-KEM-1024 + HKDF-SHA3-512 + ChaCha20-Poly1305 suite (`b.crypto.hpke`); RFC 9421 HTTP Message Signatures with derived components (`@method`, `@target-uri`, `@authority`, `@path`, `@query`) and `created` / `expires` / `nonce` / `keyid` parameters; ed25519 (legacy peers) + ML-DSA-65 (PQC peers) (`b.crypto.httpSig`); RFC 9266 TLS-Exporter channel binding for token-to-session pinning (`b.tlsExporter`); RFC 9162 CT v2 inclusion-proof verification against signed tree heads (`b.network.tls.ct.verifyInclusion`); RFC 8555 ACME client + RFC 9773 ARI (Renewal Information) polling for the CA/Browser Forum 47-day certificate lifetime (`b.acme`); RFC 8470 0-RTT inbound posture (`refuse` default; `replay-cache` opt-in with 10s SHA3-512 dedupe window; fail-closed under `pci-dss` / `fapi2`) (`b.router.create({tls0Rtt})`); RFC 9794 SecP256r1MLKEM768 codepoint added to the preferred-group order (`b.network.tls.preferredGroups`); pure-JS mTLS CA that issues clientAuth / serverAuth / dual-EKU certs with SAN entries and auto-detects the highest-PQC signature algorithm the vendored x509 library accepts (today: ECDSA-P384-SHA384 bridge; self-upgrades to SLH-DSA / ML-DSA when the X.509 ecosystem catches up), PQC TLS gates inbound + outbound (`b.mtlsCa`, `b.pqcGate`, `b.pqcAgent`).
|
|
47
47
|
- **HTTP** — router with schema-validated routes + OpenAPI publication (`b.openapi`) + AsyncAPI publication for event/streaming endpoints (`b.asyncapi`); full middleware stack (CSRF, CORS, rate-limit, security headers, CSP nonce, body parser, compression, SSE, request log, threat-aware cookie parser via `b.middleware.cookies`, request-time DB role binding via `b.middleware.dbRoleFor`, in-process CIDR fence via `b.middleware.networkAllowlist`) wired by `createApp`; HTTP/1.1 + HTTP/2 outbound client with SSRF gate (cloud-metadata IPs hard-denied unconditionally; private / loopback / link-local overridable per call), scheme + userinfo + per-host (wildcard / per-method) destination allowlist, redirects, multipart, interceptors, progress, encrypted cookie jar (`b.httpClient`, `b.ssrfGuard`, `b.safeUrl`); operator-tunable network configurability — env-driven NTP / NTS (RFC 8915 authenticated time), IPv4-or-IPv6 NTP servers, DNS with IPv6 / DoH / DoT (private-CA trust pinning via `opts.ca`) / cache / lookup timeout, outbound HTTP proxy (`HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`), runtime DPI trust-store CA additions, application-level heartbeats, TCP socket defaults (`b.network`); operator-rendered error pages with no app-frame leakage (`b.errorPage`).
|
|
48
48
|
- **Defensive parsers** — `b.safeJson` (with `maxKeys` cap defending CVE-2026-21717 V8 HashDoS), `b.safeBuffer`, `b.safeSql`, `b.safeSchema`, `b.safeUrl` (IDN mixed-script / homograph refuse), `b.safeJsonPath` (JSON-path validator refusing filter `?(...)`, deep-scan `$..`, script-shape `(@.x)` for safe Postgres JSONB ops), `b.parsers` (XML / TOML / YAML / .env), `b.config` (schema-validated env), `b.fileType` magic-byte content classification with deny-on-upload categories (image / document / archive / executable / etc.).
|
package/index.js
CHANGED
|
@@ -65,6 +65,11 @@ var vault = require("./lib/vault");
|
|
|
65
65
|
var vaultWrap = require("./lib/vault/wrap");
|
|
66
66
|
var vaultPassphraseSource = require("./lib/vault/passphrase-source");
|
|
67
67
|
var db = require("./lib/db");
|
|
68
|
+
// Standalone encrypted-DB-file lifecycle for consumers that own
|
|
69
|
+
// their own SQLite handle. Attached as b.db.fileLifecycle so it
|
|
70
|
+
// rides alongside the framework's full b.db API.
|
|
71
|
+
db.fileLifecycle = require("./lib/db-file-lifecycle").fileLifecycle;
|
|
72
|
+
var xmlC14n = require("./lib/xml-c14n");
|
|
68
73
|
var cryptoField = require("./lib/crypto-field");
|
|
69
74
|
var audit = require("./lib/audit");
|
|
70
75
|
// Attach the audit-tools dispatcher onto b.audit so operators can
|
|
@@ -184,6 +189,11 @@ var auth = {
|
|
|
184
189
|
authTime: require("./lib/auth/auth-time-tracker"),
|
|
185
190
|
accessLock: require("./lib/auth/access-lock"),
|
|
186
191
|
atoKillSwitch: require("./lib/auth/ato-kill-switch"),
|
|
192
|
+
ciba: require("./lib/auth/ciba"),
|
|
193
|
+
oid4vci: require("./lib/auth/oid4vci"),
|
|
194
|
+
oid4vp: require("./lib/auth/oid4vp"),
|
|
195
|
+
saml: require("./lib/auth/saml"),
|
|
196
|
+
openidFederation: require("./lib/auth/openid-federation"),
|
|
187
197
|
};
|
|
188
198
|
var template = require("./lib/template");
|
|
189
199
|
var render = require("./lib/render");
|
|
@@ -294,6 +304,7 @@ module.exports = {
|
|
|
294
304
|
vaultWrap: vaultWrap,
|
|
295
305
|
vaultPassphraseSource: vaultPassphraseSource,
|
|
296
306
|
db: db,
|
|
307
|
+
xmlC14n: xmlC14n,
|
|
297
308
|
cryptoField: cryptoField,
|
|
298
309
|
audit: audit,
|
|
299
310
|
auditChain: auditChain,
|
package/lib/audit.js
CHANGED
|
@@ -292,6 +292,7 @@ var FRAMEWORK_NAMESPACES = [
|
|
|
292
292
|
"mailmdn", // b.mailMdn (mailmdn.generated / mailmdn.suppressed — RFC 3798/8098 Message Disposition Notification)
|
|
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
|
+
"localdb", // b.localDb.thin (localdb.thin.opened / recovered / closed — desktop-daemon SQLite wrapper)
|
|
295
296
|
];
|
|
296
297
|
var registeredNamespaces = new Set(FRAMEWORK_NAMESPACES);
|
|
297
298
|
|
package/lib/auth/ciba.js
ADDED
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.auth.ciba
|
|
4
|
+
* @nav Identity
|
|
5
|
+
* @title CIBA (decoupled auth)
|
|
6
|
+
* @order 330
|
|
7
|
+
* @card OpenID Connect Client-Initiated Backchannel Authentication
|
|
8
|
+
* 1.0 — the "decoupled" auth flow where the relying party
|
|
9
|
+
* authenticates the user out-of-band (push notification to a
|
|
10
|
+
* phone, kiosk-driven sign-in on a separate channel) and
|
|
11
|
+
* tokens are delivered via poll / ping / push.
|
|
12
|
+
*
|
|
13
|
+
* @intro
|
|
14
|
+
* CIBA is the OpenID Connect spec for flows where the device that
|
|
15
|
+
* initiates the authentication isn't the device that completes it.
|
|
16
|
+
* Canonical use cases: a call-center agent confirming a customer
|
|
17
|
+
* identity by pushing a prompt to the customer's phone; a TPM-less
|
|
18
|
+
* POS terminal asking the user's wallet to authorize a purchase; an
|
|
19
|
+
* IVR step-up that requires the customer's mobile-app fingerprint.
|
|
20
|
+
*
|
|
21
|
+
* The relying party (RP):
|
|
22
|
+
* 1. POSTs `auth_req_id` request to the IdP's
|
|
23
|
+
* `backchannel_authentication_endpoint` with login_hint /
|
|
24
|
+
* login_hint_token / id_token_hint identifying the user, plus
|
|
25
|
+
* scope / acr_values / requested_expiry / binding_message.
|
|
26
|
+
* 2. Receives `{ auth_req_id, expires_in, interval }`.
|
|
27
|
+
* 3. Waits for token delivery via the operator-chosen mode:
|
|
28
|
+
*
|
|
29
|
+
* - **poll**: RP polls /token with grant_type=
|
|
30
|
+
* urn:openid:params:grant-type:ciba + auth_req_id every
|
|
31
|
+
* `interval` seconds; gets `authorization_pending`,
|
|
32
|
+
* `slow_down`, or the tokens.
|
|
33
|
+
* - **ping**: IdP POSTs `{ auth_req_id }` to the RP's
|
|
34
|
+
* `client_notification_endpoint`; the RP's handler then
|
|
35
|
+
* calls /token to fetch.
|
|
36
|
+
* - **push**: IdP POSTs `{ auth_req_id, access_token,
|
|
37
|
+
* id_token, refresh_token, ... }` directly. The
|
|
38
|
+
* `client_notification_token` registered with the IdP
|
|
39
|
+
* authenticates each callback.
|
|
40
|
+
*
|
|
41
|
+
* This module provides:
|
|
42
|
+
*
|
|
43
|
+
* b.auth.ciba.client.create({ ... })
|
|
44
|
+
* .startAuthentication({ loginHint, scope, bindingMessage, ... })
|
|
45
|
+
* .pollToken({ authReqId })
|
|
46
|
+
* .receivePingNotification(req) // ping mode handler
|
|
47
|
+
* .receivePushNotification(req) // push mode handler
|
|
48
|
+
*
|
|
49
|
+
* Composes b.auth.oauth for client_assertion / token-endpoint
|
|
50
|
+
* plumbing (so JWT-bearer client auth, mTLS client auth, and PAR
|
|
51
|
+
* alongside CIBA all share one set of audited credentials).
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
var lazyRequire = require("../lazy-require");
|
|
55
|
+
var validateOpts = require("../validate-opts");
|
|
56
|
+
var safeJson = require("../safe-json");
|
|
57
|
+
var safeUrl = require("../safe-url");
|
|
58
|
+
var { generateToken, sha3Hash } = require("../crypto");
|
|
59
|
+
var { AuthError } = require("../framework-error");
|
|
60
|
+
|
|
61
|
+
var httpClient = lazyRequire(function () { return require("../http-client"); });
|
|
62
|
+
var oauth = lazyRequire(function () { return require("./oauth"); });
|
|
63
|
+
var audit = lazyRequire(function () { return require("../audit"); });
|
|
64
|
+
var observability = lazyRequire(function () { return require("../observability"); });
|
|
65
|
+
var emit = validateOpts.makeNamespacedEmitters("auth.ciba", { audit: audit, observability: observability });
|
|
66
|
+
|
|
67
|
+
var DEFAULT_INTERVAL_SEC = 5;
|
|
68
|
+
var DEFAULT_EXPIRES_SEC = 600;
|
|
69
|
+
var MAX_BINDING_MSG_LEN = 200;
|
|
70
|
+
var MAX_RESPONSE_BYTES = 64 * 1024; // allow:raw-byte-literal — JSON token-response cap
|
|
71
|
+
var MIN_INTERVAL_SEC = 1;
|
|
72
|
+
var MAX_INTERVAL_SEC = 300; // allow:raw-time-literal — interval ceiling
|
|
73
|
+
|
|
74
|
+
// _emitAudit emits under the "auth.ciba.<action>" namespace; _emitMetric
|
|
75
|
+
// fires the matching observability counter. Implementations live in
|
|
76
|
+
// validateOpts.makeNamespacedEmitters; the locals are aliases so the
|
|
77
|
+
// existing call sites read identically.
|
|
78
|
+
var _emitAudit = emit.audit;
|
|
79
|
+
var _emitMetric = emit.metric;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @primitive b.auth.ciba.client.create
|
|
83
|
+
* @signature b.auth.ciba.client.create(opts)
|
|
84
|
+
* @since 0.8.62
|
|
85
|
+
* @status stable
|
|
86
|
+
* @related b.auth.oid4vci.issuer.create
|
|
87
|
+
*
|
|
88
|
+
* Build a CIBA-aware OIDC RP. Operators wire the resulting object's
|
|
89
|
+
* methods onto routes that drive the decoupled-auth flow.
|
|
90
|
+
*
|
|
91
|
+
* @opts
|
|
92
|
+
* {
|
|
93
|
+
* issuer: string, // OIDC issuer URL — required
|
|
94
|
+
* clientId: string, // RP client_id — required
|
|
95
|
+
* clientAuth: "secret"|"jwt"|"mtls", // token-endpoint auth
|
|
96
|
+
* clientSecret?: string, // when clientAuth = "secret"
|
|
97
|
+
* clientAssertionSigner?: fn(payload)→jwt, // when clientAuth = "jwt"
|
|
98
|
+
* backchannelAuthenticationEndpoint?: string, // optional — discovered when omitted
|
|
99
|
+
* tokenEndpoint?: string, // optional — discovered
|
|
100
|
+
* scope?: string|string[],
|
|
101
|
+
* deliveryMode: "poll"|"ping"|"push",
|
|
102
|
+
* clientNotificationToken?: string, // fixed token RP mints once + registers with IdP
|
|
103
|
+
* httpClientOpts?: object,
|
|
104
|
+
* allowHttp?: boolean,
|
|
105
|
+
* }
|
|
106
|
+
*
|
|
107
|
+
* @example
|
|
108
|
+
* var ciba = b.auth.ciba.client.create({
|
|
109
|
+
* issuer: "https://idp.example.com",
|
|
110
|
+
* clientId: "rp-1",
|
|
111
|
+
* clientAuth: "secret",
|
|
112
|
+
* clientSecret: process.env.CIBA_CLIENT_SECRET,
|
|
113
|
+
* scope: ["openid", "profile"],
|
|
114
|
+
* deliveryMode: "poll",
|
|
115
|
+
* });
|
|
116
|
+
* var ticket = await ciba.startAuthentication({
|
|
117
|
+
* loginHint: "alice@example.com",
|
|
118
|
+
* bindingMessage: "Authorize wire transfer of $4,200",
|
|
119
|
+
* acrValues: ["urn:mace:incommon:iap:silver"],
|
|
120
|
+
* });
|
|
121
|
+
* // → { authReqId, expiresIn, interval }
|
|
122
|
+
* var tokens = await ciba.pollToken({ authReqId: ticket.authReqId });
|
|
123
|
+
* // → { accessToken, idToken, ... } once user approves
|
|
124
|
+
*/
|
|
125
|
+
function create(opts) {
|
|
126
|
+
validateOpts.requireObject(opts, "auth.ciba.client.create", AuthError);
|
|
127
|
+
validateOpts.requireNonEmptyString(opts.issuer, "auth.ciba.client.create: issuer", AuthError, "auth-ciba/no-issuer");
|
|
128
|
+
validateOpts.requireNonEmptyString(opts.clientId, "auth.ciba.client.create: clientId", AuthError, "auth-ciba/no-client-id");
|
|
129
|
+
|
|
130
|
+
var clientAuth = opts.clientAuth || "secret";
|
|
131
|
+
if (["secret", "jwt", "mtls"].indexOf(clientAuth) === -1) {
|
|
132
|
+
throw new AuthError("auth-ciba/bad-client-auth",
|
|
133
|
+
"auth.ciba.client.create: clientAuth must be 'secret' | 'jwt' | 'mtls'");
|
|
134
|
+
}
|
|
135
|
+
if (clientAuth === "secret" && !opts.clientSecret) {
|
|
136
|
+
throw new AuthError("auth-ciba/no-client-secret",
|
|
137
|
+
"auth.ciba.client.create: clientSecret required for clientAuth='secret'");
|
|
138
|
+
}
|
|
139
|
+
if (clientAuth === "jwt" && typeof opts.clientAssertionSigner !== "function") {
|
|
140
|
+
throw new AuthError("auth-ciba/no-assertion-signer",
|
|
141
|
+
"auth.ciba.client.create: clientAssertionSigner required for clientAuth='jwt'");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
var deliveryMode = opts.deliveryMode || "poll";
|
|
145
|
+
if (["poll", "ping", "push"].indexOf(deliveryMode) === -1) {
|
|
146
|
+
throw new AuthError("auth-ciba/bad-delivery-mode",
|
|
147
|
+
"auth.ciba.client.create: deliveryMode must be 'poll' | 'ping' | 'push'");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Inner OAuth client — composes discovery, JWKS fetch, ID-token
|
|
151
|
+
// verification. CIBA's token endpoint, JWKS, and discovery are all
|
|
152
|
+
// shared with the RP's other OIDC flows so we reuse the existing
|
|
153
|
+
// primitive's caching + audit + clock-skew tolerance.
|
|
154
|
+
var inner = oauth().create({
|
|
155
|
+
issuer: opts.issuer,
|
|
156
|
+
clientId: opts.clientId,
|
|
157
|
+
clientSecret: opts.clientSecret,
|
|
158
|
+
redirectUri: opts.redirectUri || (opts.issuer + "/__ciba_no_redirect__"),
|
|
159
|
+
scope: opts.scope,
|
|
160
|
+
backchannelAuthenticationEndpoint: opts.backchannelAuthenticationEndpoint,
|
|
161
|
+
tokenEndpoint: opts.tokenEndpoint,
|
|
162
|
+
httpClientOpts: opts.httpClientOpts,
|
|
163
|
+
allowHttp: opts.allowHttp === true,
|
|
164
|
+
isOidc: true,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
var clientNotificationToken = opts.clientNotificationToken || null;
|
|
168
|
+
if ((deliveryMode === "ping" || deliveryMode === "push") && !clientNotificationToken) {
|
|
169
|
+
throw new AuthError("auth-ciba/no-notification-token",
|
|
170
|
+
"auth.ciba.client.create: clientNotificationToken required for ping/push delivery modes");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Each backchannel-authentication request mints a fresh
|
|
174
|
+
// `client_notification_token` per the spec? No — the RP registers
|
|
175
|
+
// ONE long-lived token with the IdP at registration time. Operator
|
|
176
|
+
// rotates by re-registering. Per CIBA §7.1.1.
|
|
177
|
+
|
|
178
|
+
function _basicAuthHeader() {
|
|
179
|
+
if (clientAuth !== "secret") return null;
|
|
180
|
+
var pair = opts.clientId + ":" + opts.clientSecret;
|
|
181
|
+
return "Basic " + Buffer.from(pair, "utf8").toString("base64");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function _resolveBackchannelEndpoint() {
|
|
185
|
+
// Hit discovery if not pre-configured. The inner OAuth client
|
|
186
|
+
// already has discovery cache; we ride it via the public discover()
|
|
187
|
+
// shape.
|
|
188
|
+
if (opts.backchannelAuthenticationEndpoint) return opts.backchannelAuthenticationEndpoint;
|
|
189
|
+
var disc = await inner.discover();
|
|
190
|
+
if (!disc || typeof disc.backchannel_authentication_endpoint !== "string") {
|
|
191
|
+
throw new AuthError("auth-ciba/no-backchannel-endpoint",
|
|
192
|
+
"ciba: IdP discovery doc has no backchannel_authentication_endpoint " +
|
|
193
|
+
"(set opts.backchannelAuthenticationEndpoint on create() if the IdP doesn't publish it)");
|
|
194
|
+
}
|
|
195
|
+
return disc.backchannel_authentication_endpoint;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function _resolveTokenEndpoint() {
|
|
199
|
+
if (opts.tokenEndpoint) return opts.tokenEndpoint;
|
|
200
|
+
var disc = await inner.discover();
|
|
201
|
+
if (!disc || typeof disc.token_endpoint !== "string") {
|
|
202
|
+
throw new AuthError("auth-ciba/no-token-endpoint",
|
|
203
|
+
"ciba: IdP discovery doc has no token_endpoint");
|
|
204
|
+
}
|
|
205
|
+
return disc.token_endpoint;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function _validateBindingMessage(msg) {
|
|
209
|
+
if (msg === undefined || msg === null) return null;
|
|
210
|
+
if (typeof msg !== "string") {
|
|
211
|
+
throw new AuthError("auth-ciba/bad-binding-message",
|
|
212
|
+
"ciba: bindingMessage must be a string");
|
|
213
|
+
}
|
|
214
|
+
if (msg.length > MAX_BINDING_MSG_LEN) {
|
|
215
|
+
throw new AuthError("auth-ciba/binding-message-too-long",
|
|
216
|
+
"ciba: bindingMessage exceeds " + MAX_BINDING_MSG_LEN + " chars (CIBA §7.1)");
|
|
217
|
+
}
|
|
218
|
+
// Per §7.1, binding_message MUST be plain text + restricted to
|
|
219
|
+
// characters most user-agents render legibly. Refuse control /
|
|
220
|
+
// bidi / zero-width.
|
|
221
|
+
// Codepoint scan instead of a regex character class - eslint's
|
|
222
|
+
// no-control-regex rule refuses control-char ranges in regex
|
|
223
|
+
// literals regardless of how they're spelled.
|
|
224
|
+
for (var ci = 0; ci < msg.length; ci += 1) {
|
|
225
|
+
var cc = msg.charCodeAt(ci);
|
|
226
|
+
if (cc <= 0x001f ||
|
|
227
|
+
(cc >= 0x007f && cc <= 0x009f) ||
|
|
228
|
+
(cc >= 0x200b && cc <= 0x200f) ||
|
|
229
|
+
(cc >= 0x202a && cc <= 0x202e) ||
|
|
230
|
+
(cc >= 0x2066 && cc <= 0x2069) ||
|
|
231
|
+
cc === 0xfeff) { // allow:raw-byte-literal — codepoint constants for control / bidi / zero-width / BOM
|
|
232
|
+
throw new AuthError("auth-ciba/binding-message-control-chars",
|
|
233
|
+
"ciba: bindingMessage contains control / bidi / zero-width characters");
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return msg;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function _postForm(url, body, headers) {
|
|
240
|
+
safeUrl.assertHttpUrl(url, opts.allowHttp === true);
|
|
241
|
+
var hc = httpClient();
|
|
242
|
+
var basic = _basicAuthHeader();
|
|
243
|
+
var hdrs = Object.assign({
|
|
244
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
245
|
+
"Accept": "application/json",
|
|
246
|
+
}, headers || {});
|
|
247
|
+
if (basic) hdrs["Authorization"] = basic;
|
|
248
|
+
var req = {
|
|
249
|
+
url: url,
|
|
250
|
+
method: "POST",
|
|
251
|
+
headers: hdrs,
|
|
252
|
+
body: body.toString(),
|
|
253
|
+
};
|
|
254
|
+
Object.assign(req, opts.httpClientOpts || {});
|
|
255
|
+
if (opts.allowHttp === true) req.allowedProtocols = safeUrl.ALLOW_HTTP_ALL;
|
|
256
|
+
var res = await hc.request(req);
|
|
257
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
258
|
+
var bodyText = res.body ? res.body.toString("utf8") : "";
|
|
259
|
+
var err;
|
|
260
|
+
try { err = safeJson.parse(bodyText, { maxBytes: MAX_RESPONSE_BYTES }); } catch (_e) { /* silent-catch: non-JSON IdP error body falls through to the bodyText snippet path below */ }
|
|
261
|
+
var code = (err && err.error) || ("http-" + res.statusCode);
|
|
262
|
+
var msg = (err && (err.error_description || err.error)) || bodyText.slice(0, 200); // allow:raw-byte-literal — error-message snippet length
|
|
263
|
+
var aerr = new AuthError("auth-ciba/" + code, "ciba: " + msg);
|
|
264
|
+
aerr.cibaError = err || null;
|
|
265
|
+
aerr.statusCode = res.statusCode;
|
|
266
|
+
throw aerr;
|
|
267
|
+
}
|
|
268
|
+
if (!res.body) return null;
|
|
269
|
+
try { return safeJson.parse(res.body.toString("utf8"), { maxBytes: MAX_RESPONSE_BYTES }); }
|
|
270
|
+
catch (e) {
|
|
271
|
+
throw new AuthError("auth-ciba/bad-json",
|
|
272
|
+
"ciba: response not JSON: " + ((e && e.message) || String(e)));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* @primitive b.auth.ciba.client.startAuthentication
|
|
278
|
+
* @signature b.auth.ciba.client.startAuthentication(opts)
|
|
279
|
+
* @since 0.8.62
|
|
280
|
+
*
|
|
281
|
+
* POST to the IdP's backchannel_authentication_endpoint and return
|
|
282
|
+
* a ticket with `authReqId` + `expiresIn` + `interval`. At least
|
|
283
|
+
* one of `loginHint` / `loginHintToken` / `idTokenHint` must be
|
|
284
|
+
* supplied to identify the user.
|
|
285
|
+
*
|
|
286
|
+
* @opts
|
|
287
|
+
* {
|
|
288
|
+
* loginHint?: string,
|
|
289
|
+
* loginHintToken?: string,
|
|
290
|
+
* idTokenHint?: string,
|
|
291
|
+
* scope?: string|string[],
|
|
292
|
+
* bindingMessage?: string,
|
|
293
|
+
* acrValues?: string|string[],
|
|
294
|
+
* requestedExpiry?: number,
|
|
295
|
+
* userCode?: string,
|
|
296
|
+
* }
|
|
297
|
+
*
|
|
298
|
+
* @example
|
|
299
|
+
* var ticket = await ciba.startAuthentication({
|
|
300
|
+
* loginHint: "alice@example.com",
|
|
301
|
+
* bindingMessage: "Authorize wire transfer of $4,200",
|
|
302
|
+
* });
|
|
303
|
+
* // → { authReqId, expiresIn, interval }
|
|
304
|
+
*/
|
|
305
|
+
async function startAuthentication(sopts) {
|
|
306
|
+
sopts = sopts || {};
|
|
307
|
+
if (!sopts.loginHint && !sopts.loginHintToken && !sopts.idTokenHint) {
|
|
308
|
+
throw new AuthError("auth-ciba/no-user-hint",
|
|
309
|
+
"ciba.startAuthentication: one of loginHint / loginHintToken / idTokenHint required");
|
|
310
|
+
}
|
|
311
|
+
var endpoint = await _resolveBackchannelEndpoint();
|
|
312
|
+
var body = new URLSearchParams();
|
|
313
|
+
if (sopts.loginHint) body.set("login_hint", sopts.loginHint);
|
|
314
|
+
if (sopts.loginHintToken) body.set("login_hint_token", sopts.loginHintToken);
|
|
315
|
+
if (sopts.idTokenHint) body.set("id_token_hint", sopts.idTokenHint);
|
|
316
|
+
|
|
317
|
+
var scope = sopts.scope || opts.scope || ["openid"];
|
|
318
|
+
if (Array.isArray(scope)) scope = scope.join(" ");
|
|
319
|
+
body.set("scope", scope);
|
|
320
|
+
|
|
321
|
+
if (sopts.bindingMessage !== undefined) {
|
|
322
|
+
var msg = _validateBindingMessage(sopts.bindingMessage);
|
|
323
|
+
if (msg) body.set("binding_message", msg);
|
|
324
|
+
}
|
|
325
|
+
if (Array.isArray(sopts.acrValues) && sopts.acrValues.length > 0) {
|
|
326
|
+
body.set("acr_values", sopts.acrValues.join(" "));
|
|
327
|
+
} else if (typeof sopts.acrValues === "string" && sopts.acrValues.length > 0) {
|
|
328
|
+
body.set("acr_values", sopts.acrValues);
|
|
329
|
+
}
|
|
330
|
+
if (typeof sopts.requestedExpiry === "number" &&
|
|
331
|
+
Number.isInteger(sopts.requestedExpiry) && sopts.requestedExpiry > 0) {
|
|
332
|
+
body.set("requested_expiry", String(sopts.requestedExpiry));
|
|
333
|
+
}
|
|
334
|
+
if (typeof sopts.userCode === "string") body.set("user_code", sopts.userCode);
|
|
335
|
+
|
|
336
|
+
if (clientAuth === "jwt") {
|
|
337
|
+
var assertion = await opts.clientAssertionSigner({
|
|
338
|
+
iss: opts.clientId, sub: opts.clientId, aud: endpoint,
|
|
339
|
+
iat: Math.floor(Date.now() / 1000), // allow:raw-byte-literal — ms→s
|
|
340
|
+
exp: Math.floor(Date.now() / 1000) + 300, // allow:raw-byte-literal — assertion 5m TTL
|
|
341
|
+
jti: generateToken(16),
|
|
342
|
+
});
|
|
343
|
+
body.set("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer");
|
|
344
|
+
body.set("client_assertion", assertion);
|
|
345
|
+
body.set("client_id", opts.clientId);
|
|
346
|
+
}
|
|
347
|
+
if (clientAuth === "mtls") {
|
|
348
|
+
body.set("client_id", opts.clientId);
|
|
349
|
+
}
|
|
350
|
+
if (clientNotificationToken && (deliveryMode === "ping" || deliveryMode === "push")) {
|
|
351
|
+
body.set("client_notification_token", clientNotificationToken);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
var rv = await _postForm(endpoint, body);
|
|
355
|
+
if (!rv || typeof rv.auth_req_id !== "string") {
|
|
356
|
+
throw new AuthError("auth-ciba/bad-response",
|
|
357
|
+
"ciba.startAuthentication: response missing auth_req_id");
|
|
358
|
+
}
|
|
359
|
+
var interval = typeof rv.interval === "number" && rv.interval >= MIN_INTERVAL_SEC && rv.interval <= MAX_INTERVAL_SEC
|
|
360
|
+
? rv.interval : DEFAULT_INTERVAL_SEC;
|
|
361
|
+
var expiresIn = typeof rv.expires_in === "number" && rv.expires_in > 0
|
|
362
|
+
? rv.expires_in : DEFAULT_EXPIRES_SEC;
|
|
363
|
+
|
|
364
|
+
_emitAudit("start", "success", {
|
|
365
|
+
authReqIdHash: sha3Hash("auth-ciba:" + rv.auth_req_id),
|
|
366
|
+
deliveryMode: deliveryMode,
|
|
367
|
+
hasBindingMessage: !!sopts.bindingMessage,
|
|
368
|
+
});
|
|
369
|
+
_emitMetric("started");
|
|
370
|
+
return {
|
|
371
|
+
authReqId: rv.auth_req_id,
|
|
372
|
+
expiresIn: expiresIn,
|
|
373
|
+
interval: interval,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* @primitive b.auth.ciba.client.pollToken
|
|
379
|
+
* @signature b.auth.ciba.client.pollToken(opts)
|
|
380
|
+
* @since 0.8.62
|
|
381
|
+
*
|
|
382
|
+
* Poll the IdP's /token endpoint with grant_type=ciba. Returns the
|
|
383
|
+
* tokens once the user approves; throws AuthError with code
|
|
384
|
+
* "auth-ciba/authorization_pending" or "auth-ciba/slow_down" while
|
|
385
|
+
* waiting. Operators wrap with their preferred backoff.
|
|
386
|
+
*
|
|
387
|
+
* @opts
|
|
388
|
+
* { authReqId: string }
|
|
389
|
+
*
|
|
390
|
+
* @example
|
|
391
|
+
* var tokens = await ciba.pollToken({ authReqId: ticket.authReqId });
|
|
392
|
+
* // → { accessToken, idToken, refreshToken, tokenType, scope, expiresIn, raw }
|
|
393
|
+
*/
|
|
394
|
+
async function pollToken(popts) {
|
|
395
|
+
popts = popts || {};
|
|
396
|
+
if (typeof popts.authReqId !== "string" || popts.authReqId.length === 0) {
|
|
397
|
+
throw new AuthError("auth-ciba/no-auth-req-id",
|
|
398
|
+
"ciba.pollToken: authReqId required");
|
|
399
|
+
}
|
|
400
|
+
var endpoint = await _resolveTokenEndpoint();
|
|
401
|
+
var body = new URLSearchParams();
|
|
402
|
+
body.set("grant_type", "urn:openid:params:grant-type:ciba");
|
|
403
|
+
body.set("auth_req_id", popts.authReqId);
|
|
404
|
+
if (clientAuth === "jwt") {
|
|
405
|
+
var assertion = await opts.clientAssertionSigner({
|
|
406
|
+
iss: opts.clientId, sub: opts.clientId, aud: endpoint,
|
|
407
|
+
iat: Math.floor(Date.now() / 1000), // allow:raw-byte-literal — ms→s
|
|
408
|
+
exp: Math.floor(Date.now() / 1000) + 300, // allow:raw-byte-literal — assertion 5m TTL
|
|
409
|
+
jti: generateToken(16),
|
|
410
|
+
});
|
|
411
|
+
body.set("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer");
|
|
412
|
+
body.set("client_assertion", assertion);
|
|
413
|
+
body.set("client_id", opts.clientId);
|
|
414
|
+
}
|
|
415
|
+
if (clientAuth === "mtls") body.set("client_id", opts.clientId);
|
|
416
|
+
var rv = await _postForm(endpoint, body);
|
|
417
|
+
_emitAudit("token_received", "success", {
|
|
418
|
+
authReqIdHash: sha3Hash("auth-ciba:" + popts.authReqId),
|
|
419
|
+
});
|
|
420
|
+
_emitMetric("token-received");
|
|
421
|
+
return {
|
|
422
|
+
accessToken: rv.access_token || null,
|
|
423
|
+
idToken: rv.id_token || null,
|
|
424
|
+
refreshToken: rv.refresh_token || null,
|
|
425
|
+
tokenType: rv.token_type || null,
|
|
426
|
+
scope: rv.scope || null,
|
|
427
|
+
expiresIn: typeof rv.expires_in === "number" ? rv.expires_in : null,
|
|
428
|
+
raw: rv,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* @primitive b.auth.ciba.client.parseNotification
|
|
434
|
+
* @signature b.auth.ciba.client.parseNotification(req, opts)
|
|
435
|
+
* @since 0.8.62
|
|
436
|
+
*
|
|
437
|
+
* Parse + authenticate an IdP-initiated callback to the RP's
|
|
438
|
+
* `client_notification_endpoint`. Validates the bearer
|
|
439
|
+
* `client_notification_token` (timing-safe equality) before
|
|
440
|
+
* surfacing the body. Use the returned `authReqId` to drive the
|
|
441
|
+
* RP-side flow:
|
|
442
|
+
*
|
|
443
|
+
* - In **ping** mode the body is `{ auth_req_id }`. Call
|
|
444
|
+
* `pollToken({ authReqId })` afterwards.
|
|
445
|
+
* - In **push** mode the body carries the full token-response
|
|
446
|
+
* object; no follow-up call needed.
|
|
447
|
+
*
|
|
448
|
+
* @opts
|
|
449
|
+
* { body?: object } // pre-parsed body; defaults to req.body
|
|
450
|
+
*
|
|
451
|
+
* @example
|
|
452
|
+
* app.post("/ciba/notify", function (req, res) {
|
|
453
|
+
* var info = ciba.parseNotification(req, { body: req.body });
|
|
454
|
+
* // → { authReqId, accessToken, idToken, ... }
|
|
455
|
+
* res.statusCode = 204; res.end();
|
|
456
|
+
* });
|
|
457
|
+
*/
|
|
458
|
+
function parseNotification(req, popts) {
|
|
459
|
+
popts = popts || {};
|
|
460
|
+
if (!req || !req.headers) {
|
|
461
|
+
throw new AuthError("auth-ciba/bad-notification-req",
|
|
462
|
+
"ciba.parseNotification: req with headers required");
|
|
463
|
+
}
|
|
464
|
+
var authzHeader = req.headers["authorization"] || req.headers["Authorization"];
|
|
465
|
+
if (!authzHeader || authzHeader.indexOf("Bearer ") !== 0) {
|
|
466
|
+
throw new AuthError("auth-ciba/missing-bearer",
|
|
467
|
+
"ciba.parseNotification: Authorization: Bearer header missing");
|
|
468
|
+
}
|
|
469
|
+
var presented = authzHeader.substring("Bearer ".length).trim();
|
|
470
|
+
if (presented.length === 0 || !clientNotificationToken) {
|
|
471
|
+
throw new AuthError("auth-ciba/bad-bearer",
|
|
472
|
+
"ciba.parseNotification: empty bearer or no expected token configured");
|
|
473
|
+
}
|
|
474
|
+
// Constant-time compare via the framework's primitive shape —
|
|
475
|
+
// sha3-of-each + ===-of-hash is constant-time over equal-length
|
|
476
|
+
// hashes regardless of presented length, so a length-side-channel
|
|
477
|
+
// probe can't enumerate the prefix.
|
|
478
|
+
var presentedHash = sha3Hash(presented);
|
|
479
|
+
var expectedHash = sha3Hash(clientNotificationToken);
|
|
480
|
+
if (presentedHash !== expectedHash) {
|
|
481
|
+
_emitAudit("notification_token_mismatch", "failure", {});
|
|
482
|
+
throw new AuthError("auth-ciba/wrong-bearer",
|
|
483
|
+
"ciba.parseNotification: client_notification_token does not match");
|
|
484
|
+
}
|
|
485
|
+
var body = popts.body !== undefined ? popts.body : req.body;
|
|
486
|
+
if (typeof body === "string") {
|
|
487
|
+
try { body = safeJson.parse(body, { maxBytes: MAX_RESPONSE_BYTES }); }
|
|
488
|
+
catch (e) {
|
|
489
|
+
throw new AuthError("auth-ciba/bad-notification-body",
|
|
490
|
+
"ciba.parseNotification: body is not JSON: " + ((e && e.message) || String(e)));
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
if (!body || typeof body !== "object") {
|
|
494
|
+
throw new AuthError("auth-ciba/no-notification-body",
|
|
495
|
+
"ciba.parseNotification: body required (Buffer/string parsed by middleware)");
|
|
496
|
+
}
|
|
497
|
+
if (typeof body.auth_req_id !== "string") {
|
|
498
|
+
throw new AuthError("auth-ciba/no-auth-req-id-in-body",
|
|
499
|
+
"ciba.parseNotification: body missing auth_req_id");
|
|
500
|
+
}
|
|
501
|
+
_emitAudit("notification_received", "success", {
|
|
502
|
+
authReqIdHash: sha3Hash("auth-ciba:" + body.auth_req_id),
|
|
503
|
+
mode: deliveryMode,
|
|
504
|
+
});
|
|
505
|
+
_emitMetric("notification-received");
|
|
506
|
+
return {
|
|
507
|
+
authReqId: body.auth_req_id,
|
|
508
|
+
accessToken: body.access_token || null,
|
|
509
|
+
idToken: body.id_token || null,
|
|
510
|
+
refreshToken: body.refresh_token || null,
|
|
511
|
+
tokenType: body.token_type || null,
|
|
512
|
+
scope: body.scope || null,
|
|
513
|
+
expiresIn: typeof body.expires_in === "number" ? body.expires_in : null,
|
|
514
|
+
raw: body,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
return {
|
|
519
|
+
startAuthentication: startAuthentication,
|
|
520
|
+
pollToken: pollToken,
|
|
521
|
+
parseNotification: parseNotification,
|
|
522
|
+
issuer: opts.issuer,
|
|
523
|
+
clientId: opts.clientId,
|
|
524
|
+
deliveryMode: deliveryMode,
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
module.exports = {
|
|
529
|
+
client: { create: create },
|
|
530
|
+
};
|