@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 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, sessions with optional IP / UA 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`).
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
 
@@ -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
+ };