@blamejs/core 0.14.21 → 0.14.22

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,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.14.x
10
10
 
11
+ - v0.14.22 (2026-06-04) — **RFC 9101 signed request objects: a JAR request-object builder, a classical JWS signer for external interop, and pushed authorization requests that carry `request=`.** The framework can now mint JWT-Secured Authorization Requests, completing the JAR surface whose verify side (`b.auth.jar.parse`) shipped in v0.12.31 with the builder documented as waiting on a classical signer. `b.auth.jws.sign` is that signer — a compact-JWS producer for RS / PS / ES / EdDSA keys that exists strictly for interop with external ecosystems (authorization servers and relying parties that require classical algorithms); the framework's own tokens stay on the PQC-first signer. `b.auth.jar.build` mints the RFC 9101 request object on top of it, and `pushAuthorizationRequest` composes both so a pushed authorization request can carry the signed `request=` parameter — the FAPI 2.0 message-signing client shape. The OAuth client-attestation builder now composes the same promoted signer internally, with identical wire output. **Added:** *`b.auth.jar.build` — RFC 9101 request-object builder* — Mints the JWT-Secured Authorization Request: header `typ: oauth-authz-req+jwt` (RFC 9101 §10.8), `iss` = the client_id and `aud` = the authorization server (§5), `response_type` and `client_id` required as claims (§4), and every authorization parameter carried as a claim. A params object containing `request` or `request_uri` is refused (§4 forbids nesting), reserved-claim collisions are refused, and a `params.client_id` that disagrees with `opts.clientId` is refused. The JWT carries a short `exp` (default 5 minutes, `expiresInMs`-overridable), `nbf`, and a random `jti` for FAPI 2.0 message signing. The signing algorithm derives from the supplied key; `none` is impossible. Round-trips against the existing `b.auth.jar.parse` verifier. · *`b.auth.jws.sign` — classical compact-JWS signer for external interop* — Signs a compact JWS with an RS / PS / ES (P-256/P-384/P-521) / EdDSA key, deriving the algorithm from the key per RFC 7518 §3.1 and refusing `none`, HMAC, and algorithm/key mismatches; a caller-supplied `header.alg` cannot override the derived algorithm (algorithm-substitution closed). This primitive exists for interop with external ecosystems that require classical JOSE — JAR request objects, attestation JWTs, and similar cross-vendor surfaces. It is never the framework-internal token default: `b.auth.jwt` remains the PQC-first signer for the framework's own tokens. · *Pushed authorization requests can carry a signed request object (RFC 9126 §3)* — `pushAuthorizationRequest` accepts a `signedRequestObject` option (`{ key, alg?, kid?, audience?, expiresInMs? }`). When present, the authorization parameters are minted into a JAR request object and the PAR body carries `request=<jwt>` plus only the client-authentication material RFC 9126 allows alongside it; the bare authorization parameters are not duplicated in the form. Absent, the existing plain-form path sends the same key/value set as before. · *`validateOpts.assignOwnEnumerable` — shared prototype-safe claim merge* — Consolidates the own-enumerable key merge with prototype-pollution and reserved-key guards that the request-object builder, the classical signer, and the client-attestation builder all need. Existing call sites compose it; behavior is unchanged. **Changed:** *OAuth client-attestation signing composes the promoted classical signer* — The attestation builder's private JWS assembly moved to the shared `b.auth.jws.sign` internals. Wire output is identical — same headers, claim order, algorithm selection, and accepted-algorithm set — and the `auth-oauth/attestation-*` error codes are preserved, so operators routing alerts on those codes see no change. · *Object key-copy sites compose the prototype-safe merge* — The long-running-operation status reader, the deny-path response-header merge, the HTTP client's cross-origin redirect header strip, and the trace-log logger wrapper now copy keys through `validateOpts.assignOwnEnumerable` instead of raw bracket-assign loops, so a `__proto__`/`constructor`/`prototype` key in the source object can never graft onto the copy. Behavior is otherwise unchanged. **Removed:** *Maintainer planning note removed from the repository* — `memory/specs/node-26-map-getorinsert-migration.md` — a maintainer-local planning note that had been committed since v0.11.2 — is gone from the repository (it was never part of the npm package). The Node 26 detector allowlists in the pattern catalog now carry their per-site annotations standalone, and `SECURITY.md` / `.pinact.yaml` no longer reference maintainer-local note paths. **Security:** *`jar.parse` returns prototype-safe authorization params (CWE-1321)* — A verified request object whose payload carried a `__proto__` claim (JSON.parse materializes it as an own key) previously grafted that claim's value onto the returned `params` object's prototype chain — a signature from a registered-but-malicious client was sufficient. The params object is now built through the prototype-safe merge; `__proto__`/`constructor`/`prototype` claim names are inert and are not copied. · *`jws.sign` refuses `b64` and `crit` protected-header members* — RFC 7797 `b64: false` changes the JWS signing input (the payload is signed raw, not base64url-encoded) and RFC 7515 §4.1.11 `crit` promises the producer implements every extension it names. The signer always base64url-encodes the payload and implements no header extensions, so passing either member through minted a JWS whose header advertised semantics its signature was not computed under — a compliant verifier derives a different signing input or refuses the critical header. Both members are now refused with `auth-jwt-external/sign-unsupported-header`; unencoded-payload support would land as an explicit feature, not a header pass-through. **Detectors:** *raw-key-copy-loop-bypasses-assign-own-enumerable* — Refuses raw `out[keys[i]] = src[keys[i]]` bracket-assign copy loops in `lib/` — the shape behind the `jar.parse` finding. Key-copy sites compose `validateOpts.assignOwnEnumerable`; the two genuinely-different bodies (audit-chain hash canonicalization, schema-shape transforms) carry allowlist entries with structural reasons. · *jose-header-passthrough-without-b64-crit-refusal* — Any caller-supplied JOSE protected-header pass-through must name-refuse `b64`/`crit` before signing. · *no-tracked-internal-notes gate* — The pattern catalog now refuses any tracked file under `memory/`, `notes/`, or `.scratch*` paths at commit time. **Migration:** *No action required; everything is additive* — The JAR builder, the classical signer, the PAR `signedRequestObject` option, and the shared merge helper are new surface. Existing `jar.parse` callers, attestation flows, and plain PAR requests behave exactly as before.
12
+
11
13
  - v0.14.21 (2026-06-04) — **SCIM Bulk forward references, an atomic api-encrypt replay gate, OID4VCI `x5c` proofs, HEAD responses without bodies, and a sweep that makes every accepted option do what its documentation says.** This release closes correctness and conformance gaps across recently shipped standards surfaces, plus a framework-wide sweep for options that were accepted but never read. SCIM `/Bulk` resolves `bulkId` cross-references regardless of operation order; the `apiEncrypt` middleware closes a concurrent-replay window on multi-replica session stores and validates its numeric options at boot; OID4VCI accepts `x5c` holder-key binding; `problemDetails` spreads its documented `extensions` object as RFC 9457 sibling members; `openapiServe` / `asyncapiServe` answer HEAD without a body; and a batch of entry points now throw on mistyped numeric options instead of silently defaulting. Options whose documented behavior was never implemented are now wired; options that could never do anything are removed and refuse as unknown. **Removed:** *Options that could never do anything now refuse as unknown* — The sweep removed accepted-but-impossible option keys: the `securityTxt` / `traceLogCorrelation` / tracer / TLS-RPT `audit` keys (these surfaces emit no audit rows), TLS-RPT `reportingMta` (not an RFC 8460 report field), `dsr.create` `observability` and create-time `verifyContext` (the per-call `process()` option of the same name is unchanged), `breakGlass.init` `now` (a single init-time timestamp cannot coherently override later time reads), WCAG `checkAll`, mail-deploy `compliance`, and bucket-ops `ca` (TLS trust is owned by the PQC agent — use `NODE_EXTRA_CA_CERTS` or `opts.agent`). Passing one of these now throws the standard unknown-option error. **Fixed:** *SCIM `/Bulk` resolves `bulkId` cross-references regardless of operation order (RFC 7644 §3.7.2)* — Forward references (an operation referencing a resource a later operation creates — the shape Okta and Entra emit) now execute in dependency order; circular references are refused with status 409; a reference to an undeclared `bulkId`, or to an operation that failed, fails that operation with `invalidValue`. References are resolved on BOTH surfaces the spec allows: operation data (`"value": "bulkId:u1"`) and the operation path (`PATCH /Groups/bulkId:g1` targeting a group created in the same request) — path references order, substitute, and fail exactly like data references. Previously an unresolvable reference passed the literal `bulkId:<id>` token through to your resource adapter as if it were a real id. The Bulk response keeps results in original request order, and `failOnErrors` still short-circuits. · *OID4VCI `x5c` holder-key binding implemented (RFC 7515 §4.1.6; OID4VCI §8.2.1.1)* — The proof-JWT verifier named `x5c` as a valid holder-key binding in its own error message but always refused `x5c`-only proofs. The certificate chain is now shape-validated (standard base64 DER, leaf first), the leaf certificate's public key becomes the holder key at the same self-asserted trust level as an inline `jwk`, and a new optional `validateX5c(chainDerBuffers, header)` hook lets the issuer enforce chain trust (PKI anchoring, EKU checks, attestation-CA allowlists) before the key is accepted. · *OID4VCI expired `c_nonce` refuses with a typed error* — A wallet whose access token outlived the shorter `c_nonce` TTL hit an untyped `TypeError` from the nonce comparison; issuance now refuses with `auth-oid4vci/c-nonce-expired` so handlers keying on typed codes respond correctly. The refusal direction is unchanged — no credential was ever minted on this path. · *OID4VP DCQL numeric claim-path segments must be non-negative integers (OpenID4VP 1.0 §7.1.1)* — A query carrying `-1`, `1.5`, `NaN`, or `Infinity` as an array-index segment previously validated and then silently never matched; it now throws at build time, surfacing the malformed query to the verifier author instead of degrading to a silent non-match. · *`problemDetails` `extensions` spread as sibling members (RFC 9457 §3.2)* — `send` and `create` documented an `extensions` object as the way to attach extension members, but emitted it as a literal nested `extensions` member instead. The keys now land as top-level siblings; reserved fields (`type` / `title` / `status` / `detail` / `instance`) cannot be overridden by an extension key, prototype-pollution-shaped keys are dropped, and a direct top-level key wins on collision. · *`cspReport` honors `audit: false`* — The documented audit knob was accepted but never read; the `csp.violation` audit row fired unconditionally for every report. `audit: false` now suppresses the row while reports are still normalized and delivered to `onReport`. The default (audit on) is unchanged, and `maxBytes` now throws at config time on a non-positive-integer value instead of silently reverting to 64 KiB. · *`openapiServe` / `asyncapiServe` HEAD responses carry no body (RFC 9110 §9.3.2)* — Both middlewares advertised GET/HEAD but answered HEAD with the full JSON / YAML document as a body. HEAD now returns the GET headers (including `Content-Length`) with an empty body, matching the rest of the framework's document-serving middlewares. · *Config-time numeric options throw on bad input across entry points* — A mistyped numeric option now throws at `create()` instead of silently becoming the default or garbage downstream: `scimServer` `maxPageSize` (a non-number propagated `NaN` into your `impl.list({ count })` and `ServiceProviderConfig`), `mail.send.deliver` `retry.maxAttempts` / `timeouts.mxLookupMs` / `timeouts.perHostMs`, the `redis` client `db` / `connectTimeoutMs` / `commandTimeoutMs` / `maxReconnectAttempts` (a non-numeric value made the reconnect-cap check false and silently disabled the bound entirely), `pubsub` cluster `pollIntervalMs` / `retentionMs` / `pruneEveryMs`, and SQS queue `visibilityTimeoutSec` / `waitTimeSec` (`0` short-polling stays valid). · *Accepted-but-unread options now do what their documentation says* — The db-backed `config` reloader's `audit` knob gates its `config.reload.*` rows; `honeytoken` honors the documented injectable audit sink (`{ audit: yourSink }`) instead of always emitting to the global sink; `dora`'s `observability` knob gates its report counter, and that counter now actually emits (it previously called a method the observability module doesn't export, and the failure was swallowed); flag evaluation-context `tenantKey` sets the tenant axis; the WCAG `aria` / `forms` / `tables` sub-scanners stamp `scopeUrl` on every finding so direct callers can correlate findings to a source document; `safeRedirect`'s documented `base` lets a same-origin absolute URL pass without an explicit allowlist (cross-origin still refused); and object-store bucket operations honor a per-call `actor` override on audit rows. **Security:** *api-encrypt concurrent-replay window closed (CWE-367)* — On a multi-replica session store, two concurrent requests carrying the same valid counter could both pass the monotonic replay check and execute twice — an attacker who captured one encrypted request could replay it concurrently and have a non-idempotent route run twice. The per-session path now claims each `(session, counter)` tuple through the same atomic nonce store the bootstrap path uses; exactly one concurrent request wins and the loser is refused with the standard rejection shape. The claim lives until the session expires (not just the staleness window), so a failed best-effort session write cannot re-open the tuple for late replay. The bootstrap response counter is also persisted correctly on serializing session stores, fixing a response-replay false positive on the second request of a session. · *api-encrypt envelope metadata is authenticated (AEAD-bound)* — The envelope's plaintext fields — `_ts`, `_nonce`, `_sid`, `_ctr` — were not bound into the ciphertext, so a captured request could be replayed past the staleness window with a rewritten `_ts`, and a captured response could be replayed to the client under a bumped `_ctr` (the client's monotonic check reads the plaintext field). Every request and response envelope now binds its metadata as AEAD associated data on both protocol halves; any rewrite fails authenticated decryption and is refused (server: standard rejection; client: typed `CLIENT_RESPONSE_TAMPERED`). The client also advances its response counter only after authenticated decryption, so a refused forgery cannot poison the monotonic check and block subsequent genuine responses. · *api-encrypt numeric options validated at boot* — A mistyped `replayWindowMs` (for example the string `"5m"`) made the timestamp-staleness comparison always false and silently disabled that replay defense. `replayWindowMs`, `maxDecryptedBytes`, and `pruneIntervalMs` now throw at config time across `create`, `client`, and `httpClient.encrypted`. **Detectors:** *Three new codebase-pattern gates* — An option key accepted by a validation allowlist must be read by the file that accepts it (an accepted-but-never-read key is an advertised knob with no implementation); entry-point numeric options must validate rather than coerce-or-default (`Number(opts.x) || DEFAULT` swallows exactly the typo the config-time tier exists to surface); and a dispatcher that admits HEAD must suppress the response body somewhere in the same file. **Migration:** *Delete removed option keys; everything else is a behavioral fix or additive* — If you pass one of the removed keys listed under Removed, delete it — it did nothing before and now throws the standard unknown-option error. Callers passing valid options see conforming behavior with no code change; the new `validateX5c` hook and the per-finding `scopeUrl` stamp are additive. · *apiEncrypt middleware and client must upgrade together* — Binding the envelope metadata into the AEAD changes what the ciphertext authenticates, so a pre-0.14.21 client cannot talk to a 0.14.21 middleware or vice versa — mixed-version peers fail authenticated decryption and are refused. Both halves ship in this package; a single service upgrading normally is unaffected. If separate services pin different framework versions and speak apiEncrypt to each other, upgrade them together.
12
14
 
13
15
  - v0.14.20 (2026-06-02) — **OAuth Rich Authorization Requests and client attestation, a sealed-field unseal rate cap, DMARC forensic-report parsing, monitor-mode browser-isolation headers, and FedCM / Storage-Access fetch-metadata.** This release extends several standards surfaces the framework already covered in part. The OAuth client gains RFC 9396 Rich Authorization Requests: a typed `authorizationDetails` array is validated before the request and the granted details in the token response are cross-checked, refusing a grant the OP broadened beyond what was asked. The client also gains the OAuth 2.0 Attestation-Based Client Authentication primitives — it can build the `OAuth-Client-Attestation` / `-PoP` JWT pair and verify an inbound attestation. Sealed-field reads gain an opt-in unseal-failure rate cap that throttles a decryption-oracle / brute-force burst against attacker-written sealed columns (CWE-307). The inbound mail stack gains a DMARC forensic (RUF) report parser, the inverse of the aggregate-report parser. On the browser side, the security-headers middleware adds report-only COOP / COEP / Document-Policy variants for safe cross-origin-isolation rollout plus the embedder Require-Document-Policy and Service-Worker-Allowed headers, and the fetch-metadata middleware recognizes the FedCM `webidentity` destination and the Storage-Access request headers first-class. Observability adds a batch of current OpenTelemetry semantic-convention attributes. Every addition is additive or opt-in: an operator who sets no new option, and configures no rate cap, sees prior behavior unchanged. **Added:** *OAuth client: RFC 9396 Rich Authorization Requests (`authorizationDetails`)* — The OAuth / OIDC client accepts an `authorizationDetails` array (RFC 9396 §2 — each element a typed object) on the authorization and pushed-authorization-request paths; it is validated at config time (every element must be an object carrying a string `type`) and serialized as the `authorization_details` parameter, with a cap on the serialized payload. When `verifyAuthorizationDetails` is set, the granted `authorization_details` returned in the token response (RFC 9396 §7) is cross-checked against the request, and a grant that exceeds what was requested is refused — defending against an upstream broadened-grant privilege escalation. Without `authorizationDetails`, the request is unchanged. · *OAuth 2.0 Attestation-Based Client Authentication* — `b.auth.oauth.buildClientAttestation` and `buildClientAttestationPop` mint the `OAuth-Client-Attestation` JWT (a client-backend-signed assertion binding a per-instance key, `typ: oauth-client-attestation+jwt`) and its per-instance `OAuth-Client-Attestation-PoP` proof; `verifyClientAttestation` validates the pair, including the alg allowlist (`ATTESTATION_ALGS`), the audience, and a constant-time `jti` check. This is the wallet / per-device client-authentication shape used in OpenID4VCI and the EUDI Wallet, an alternative to a shared `client_secret`. · *`b.cryptoField.configureUnsealRateCap` — sealed-field decryption-oracle throttle* — An opt-in per-(actor, table, column) sliding-window cap on sealed-column unseal failures. A DB-write attacker who can place crafted `vault:` / `vault.aad:` bytes in sealed columns can force KEM decapsulation / AEAD verification on attacker-controlled input on every read; past `threshold` failures within `windowMs`, further unseal attempts for that tuple are refused for `cooldownMs` with a typed `CryptoFieldRateError` and a distinct `system.crypto.unseal_rate_exceeded` audit event (CWE-307; OWASP ASVS v5 §2.2.1; NIST SP 800-63B §5.2.2). Default off — with no cap configured, `unsealRow` behaves exactly as before (null the field, emit `system.crypto.unseal_failed`); the cap is count-based and lazily pruned, with no background timer. · *`b.mail.dmarc.parseForensicReport` — RFC 6591 forensic (RUF) report parser* — Parses a DMARC failure (forensic) report: a `multipart/report; report-type=feedback-report` message (RFC 6591 §3) whose `message/feedback-report` subpart carries the `Feedback-Type: auth-failure` fields, with the required-field set validated (`Auth-Failure` and the other §3.1 fields) and a part-count and byte cap bounding a hostile report. It is the inbound inverse of the aggregate-report path, so an operator ingesting RUF mail has a parser symmetric with the existing aggregate parser and the v0.14.19 aggregate builder. · *Monitor-mode cross-origin-isolation and embedder headers* — `b.middleware.securityHeaders` gains `coopReportOnly`, `coepReportOnly`, and `documentPolicyReportOnly` — set a policy string to emit the matching `*-Report-Only` header so a UA evaluates and reports violations without enforcing, the safe way to stage a COOP / COEP / Document-Policy rollout (WHATWG HTML cross-origin isolation; W3C Document Policy). `requireDocumentPolicy` emits the embedder `Require-Document-Policy` a parent demands of subframes, and `serviceWorkerAllowed` emits `Service-Worker-Allowed` to broaden a service worker's registration scope (W3C Service Workers). All default off. · *fetch-metadata: FedCM `webidentity` and Storage-Access headers* — `b.middleware.fetchMetadata` recognizes the `webidentity` `Sec-Fetch-Dest` (a FedCM credentialed request) first-class and adds `deniedDest` — a list of destinations refused outright on the gated methods regardless of site, so a `webidentity` request hitting a route that is not an identity endpoint is refused. `allowStorageAccess` (default true) governs whether a request carrying `Sec-Fetch-Storage-Access: active` / `inactive` (the Storage Access API headers) is allowed, and `strictDest` throws at config time on an `allowedDest` / `deniedDest` value outside the known `Sec-Fetch-Dest` vocabulary, catching a typo at boot. · *OpenTelemetry semantic-convention attributes* — The observability semantic-convention map gains a batch of current stable attributes: `peer.service`, the `faas.*` function attributes (`name` / `version` / `instance` / `trigger`), `deployment.environment.name`, `telemetry.distro.*`, `otel.scope.*`, and a Kubernetes subset (`k8s.cluster.name` and the node / container / cronjob / daemonset / job / replicaset / statefulset names), so a span or metric carrying these keys is recognized and emitted under the canonical name. **Fixed:** *`b.auth.sdJwtVc.holder` signed the key-binding JWT with a non-key-derived algorithm* — The holder helper defaulted the key-binding JWT (KB-JWT) signing algorithm to a fixed `ES256` regardless of the holder key type, so a non-EC-P-256 holder key produced a presentation that either could not be built (an Ed25519 or P-384 key) or whose KB-JWT header advertised `ES256` while the signature used the actual key — a self-invalid token a verifier rejects. The algorithm is now derived from the holder key: ES256 / ES384 by EC curve, EdDSA for Ed25519 / Ed448, and ML-DSA-87 / ML-DSA-65 for an ML-DSA key. An RSA holder key, which has no supported KB-JWT algorithm, is refused at `holder.create` with a clear error instead of producing a broken presentation. An EC P-256 holder key, and any explicit `algorithm`, behave exactly as before. **Security:** *Throttle a sealed-column decryption oracle (opt-in)* — `b.cryptoField.configureUnsealRateCap` lets an operator bound repeated unseal failures against sealed columns, so an attacker who can write crafted bytes into a sealed field cannot hammer the KEM / AEAD verify path indefinitely while only an off-band alert rule notices the burst (CWE-307). It is opt-in because a sensible threshold and window depend on a deployment's legitimate sealed-read volume; the always-on defense (null the field plus an audit event on every unseal failure) is unchanged. · *Refuse a broadened OAuth grant* — With `verifyAuthorizationDetails` enabled, the OAuth client refuses a token response whose granted `authorization_details` exceed the requested set (RFC 9396 §7), so a compromised or misbehaving authorization server cannot silently widen a client's authorization beyond what the request asked for. **Migration:** *No action required; additions are additive or opt-in* — The OAuth `authorizationDetails` request parameter, the granted-details cross-check, the client-attestation builders / verifier, the sealed-field unseal rate cap, the DMARC forensic-report parser, the monitor-mode and embedder browser headers, the fetch-metadata FedCM / Storage-Access options, and the new OpenTelemetry attribute keys are all additive or opt-in. A client that passes no `authorizationDetails`, an operator who calls no `configureUnsealRateCap`, and a security-headers / fetch-metadata configuration that sets none of the new options all see prior behavior byte-for-byte unchanged. The one behavior change is the SD-JWT VC holder fix: a `b.auth.sdJwtVc.holder` built with an RSA holder key is now refused at `holder.create` (RSA has no supported KB-JWT algorithm; it previously produced a self-invalid presentation) — switch such a holder to an EC P-256 / P-384, Ed25519, or ML-DSA key. EC P-256 holders and any explicit `algorithm` are unaffected.
package/index.js CHANGED
@@ -32,7 +32,7 @@ _tls.DEFAULT_MIN_VERSION = "TLSv1.3";
32
32
  * error-handler, body-parser, csp-nonce, compression,
33
33
  * health, api-encrypt), httpClient, websocket,
34
34
  * websocketChannels, nonceStore
35
- * Auth: auth.{password,totp,passkey,jwt,oauth,lockout}, authHeader
35
+ * Auth: auth.{password,totp,passkey,jwt,jws,oauth,jar,lockout}, authHeader
36
36
  * Render: template, render, staticServe, forms, errorPage
37
37
  * App: createApp, jobs, mail, mailBounce, scheduler,
38
38
  * appShutdown
@@ -247,6 +247,10 @@ var auth = {
247
247
  jwt: Object.assign({},
248
248
  require("./lib/auth/jwt"),
249
249
  { verifyExternal: require("./lib/auth/jwt-external").verifyExternal }),
250
+ // Classical-JOSE signer (RS/PS/ES/EdDSA) for external-ecosystem interop —
251
+ // OPs/RPs that require a classical-signed request object / assertion. Never
252
+ // the framework-internal token default; b.auth.jwt stays PQC-only.
253
+ jws: { sign: require("./lib/auth/jwt-external").signExternal },
250
254
  oauth: require("./lib/auth/oauth"),
251
255
  jar: require("./lib/auth/jar"),
252
256
  lockout: require("./lib/auth/lockout"),
package/lib/auth/jar.js CHANGED
@@ -15,14 +15,26 @@
15
15
  * can verify they arrived exactly as the client sent them.
16
16
  *
17
17
  * <code>b.auth.jar.parse(jar, opts)</code> verifies an incoming
18
- * request object: the signature is checked through
19
- * <code>b.auth.jwt.verifyExternal</code> (mandatory <code>algorithms</code>
20
- * allowlist — no <code>alg: "none"</code>, no HMAC-vs-RSA confusion,
21
- * no JWE-on-a-JWS-verifier), <code>iss</code> is pinned to the
22
- * expected <code>clientId</code>, <code>aud</code> to this server's
23
- * issuer identifier, the request object's <code>client_id</code>
24
- * claim must match the client, and the authorization parameters are
25
- * returned with the JWT envelope claims stripped.
18
+ * request object (the authorization-server side): the signature is
19
+ * checked through <code>b.auth.jwt.verifyExternal</code> (mandatory
20
+ * <code>algorithms</code> allowlist — no <code>alg: "none"</code>, no
21
+ * HMAC-vs-RSA confusion, no JWE-on-a-JWS-verifier), <code>iss</code>
22
+ * is pinned to the expected <code>clientId</code>, <code>aud</code> to
23
+ * this server's issuer identifier, the request object's
24
+ * <code>client_id</code> claim must match the client, and the
25
+ * authorization parameters are returned with the JWT envelope claims
26
+ * stripped.
27
+ *
28
+ * <code>b.auth.jar.build(params, opts)</code> mints a request object
29
+ * (the client side): the authorization-request parameters become
30
+ * claims of a JWT signed with the client's classical key via
31
+ * <code>b.auth.jws.sign</code> (RS/PS/ES/EdDSA — the interop algs an
32
+ * authorization server accepts), typed <code>oauth-authz-req+jwt</code>,
33
+ * with <code>iss</code>/<code>aud</code> pinned and a short FAPI-2
34
+ * <code>exp</code>. <code>build</code> and <code>parse</code>
35
+ * round-trip. The framework's own tokens stay PQC-signed
36
+ * (<code>b.auth.jwt</code>); JAR signs classically only because no
37
+ * standard authorization server verifies a PQC request object today.
26
38
  *
27
39
  * <strong>Anti-nesting (RFC 9101 §6.3):</strong> a request object
28
40
  * may not itself carry a <code>request</code> or <code>request_uri</code>
@@ -35,33 +47,48 @@
35
47
  * shapes against a JWKS public-key trust source. JAR adds the
36
48
  * request-object-specific bindings on top.
37
49
  *
38
- * <strong>Emitting</strong> a request object (the client side) is
39
- * deferred-with-condition: it requires signing with the client's
40
- * key under a classical JWS algorithm (RS256 / ES256 / EdDSA), and
41
- * the framework's own JWT signer (<code>b.auth.jwt.sign</code>) is
42
- * PQC-only (ML-DSA / SLH-DSA) for the tokens the framework itself
43
- * issues a PQC-signed request object would not interoperate with
44
- * any standard authorization server today. blamejs sits on the
45
- * authorization-server side here (it verifies client request
46
- * objects); client-side emission re-opens when a classical
47
- * <code>b.auth.jws.sign</code> primitive lands or operators surface
48
- * the need. Until then clients sign their request objects with
49
- * their existing JOSE tooling.
50
+ * The request object is signed with a classical JWS algorithm
51
+ * (RS/PS/ES/EdDSA) because no standard authorization server verifies a
52
+ * PQC-signed request object today; the framework's own JWT signer
53
+ * (<code>b.auth.jwt.sign</code>) stays PQC-only (ML-DSA / SLH-DSA) for
54
+ * the tokens blamejs itself issues. <code>build</code> composes
55
+ * <code>b.auth.jws.sign</code> for the classical signature the
56
+ * client-side emission this module previously left to the operator's
57
+ * own JOSE tooling now ships in-framework.
50
58
  *
51
59
  * @card
52
- * RFC 9101 JWT-Secured Authorization Request (server side) verify
53
- * the OAuth request object with mandatory alg allowlist, iss +
54
- * client_id binding, audience pinning, and anti-nesting.
60
+ * RFC 9101 JWT-Secured Authorization Request build the client
61
+ * request object (classical JWS, typed, iss/aud-pinned, short exp) and
62
+ * verify it on the server with mandatory alg allowlist, client_id
63
+ * binding, and anti-nesting.
55
64
  */
56
65
 
57
66
  var jwtExternal = require("./jwt-external");
58
67
  var validateOpts = require("../validate-opts");
68
+ var C = require("../constants");
69
+ var bCrypto = require("../crypto");
59
70
  var { defineClass } = require("../framework-error");
60
71
 
61
72
  var AuthJarError = defineClass("AuthJarError", { alwaysPermanent: true });
62
73
 
63
74
  var JAR_TYP = "oauth-authz-req+jwt";
64
75
 
76
+ // RFC 9101 §4 — a request object MUST carry response_type + client_id as
77
+ // claims (they are REQUIRED OAuth 2.0 authorization-request parameters).
78
+ var REQUIRED_REQUEST_PARAMS = ["response_type", "client_id"];
79
+
80
+ // Claims the builder sets itself from opts (iss = client_id, aud =
81
+ // audience) plus the JWT lifetime claims it mints (exp/nbf/iat/jti).
82
+ // Operator params colliding with iss / aud are refused so a params.iss
83
+ // can't shadow the builder-pinned issuer; the lifetime claims are owned by
84
+ // the builder's exp/nbf/jti opts, not by free-form params.
85
+ var BUILDER_OWNED_CLAIMS = ["iss", "aud", "exp", "nbf", "iat", "jti"];
86
+
87
+ // Default request-object lifetime. FAPI-2 message-signing wants a short
88
+ // window; 5 minutes mirrors the attestation-PoP floor and is overridable
89
+ // via opts.expiresInMs.
90
+ var DEFAULT_JAR_EXPIRES_MS = C.TIME.minutes(5);
91
+
65
92
  // JWT-standard claims that are request-object envelope metadata, not
66
93
  // OAuth authorization parameters — stripped from the returned params.
67
94
  var ENVELOPE_CLAIMS = ["iss", "aud", "exp", "iat", "nbf", "jti"];
@@ -164,16 +191,151 @@ async function parse(jar, opts) {
164
191
  "jar.parse: request object must not carry `request` or `request_uri` (RFC 9101 §6.3)");
165
192
  }
166
193
 
167
- var params = {};
168
- var keys = Object.keys(payload);
169
- for (var i = 0; i < keys.length; i++) {
170
- if (ENVELOPE_CLAIMS.indexOf(keys[i]) === -1) params[keys[i]] = payload[keys[i]];
171
- }
194
+ // Authorization parameters = every claim minus the JWT envelope.
195
+ // assignOwnEnumerable skips the prototype-pollution sentinel keys a
196
+ // verified-but-hostile request object carrying a `__proto__` claim
197
+ // (JSON.parse materializes it as an own key) must not graft onto the
198
+ // returned params object's prototype chain (CWE-1321).
199
+ var params = validateOpts.assignOwnEnumerable({}, payload, ENVELOPE_CLAIMS);
172
200
  return { params: params, claims: payload };
173
201
  }
174
202
 
203
+ /**
204
+ * @primitive b.auth.jar.build
205
+ * @signature b.auth.jar.build(params, opts)
206
+ * @since 0.14.22
207
+ * @status stable
208
+ * @compliance soc2
209
+ * @related b.auth.jar.parse, b.auth.jws.sign
210
+ *
211
+ * Mint an RFC 9101 request object — the client side of JWT-Secured
212
+ * Authorization Requests. The authorization-request parameters in
213
+ * <code>params</code> become claims of a JWT signed with the client's
214
+ * classical key, ready to send as the <code>request</code> parameter (or
215
+ * pushed through PAR). The inverse of <code>b.auth.jar.parse</code>; the
216
+ * two round-trip.
217
+ *
218
+ * The protected header carries <code>typ: "oauth-authz-req+jwt"</code>
219
+ * (RFC 9101 §10.8 — explicit typing closes the cross-JWT-confusion vector
220
+ * where a token minted for another purpose is replayed as a request
221
+ * object). <code>iss</code> is set to <code>opts.clientId</code> and
222
+ * <code>aud</code> to <code>opts.audience</code> — the authorization
223
+ * server's issuer identifier (RFC 9101 §5; the FAPI 2.0 message-signing
224
+ * profile requires both). <code>response_type</code> and
225
+ * <code>client_id</code> are REQUIRED claims (RFC 9101 §4); the builder
226
+ * refuses if either is absent from <code>params</code>. A request object
227
+ * <strong>MUST NOT</strong> nest <code>request</code> /
228
+ * <code>request_uri</code> (RFC 9101 §4) — supplying either in
229
+ * <code>params</code> is refused at build, the mirror of
230
+ * <code>parse</code>'s anti-nesting check.
231
+ *
232
+ * <code>exp</code> defaults to 5 minutes (FAPI-2 wants a short signing
233
+ * window; tune via <code>opts.expiresInMs</code>); <code>nbf</code> is set
234
+ * to <code>iat</code> and a random <code>jti</code> is minted so the AS can
235
+ * single-use the object. The signing <code>alg</code> is derived from
236
+ * <code>opts.key</code> via <code>b.auth.jws.sign</code> (RS/PS/ES/EdDSA);
237
+ * <code>alg: "none"</code> is impossible — the signer refuses it. This is
238
+ * the classical-interop path: the framework's own tokens stay PQC-signed.
239
+ *
240
+ * @opts
241
+ * {
242
+ * clientId: string, // required — → iss + must equal params.client_id
243
+ * audience: string, // required — AS issuer identifier → aud
244
+ * key: KeyObject|PEM|JWK, // required — client's classical signing key
245
+ * alg?: string, // JWS alg override (default inferred from the key)
246
+ * kid?: string, // protected-header kid (JWKS selection at the AS)
247
+ * expiresInMs?: number, // exp = iat + this (default: 5m; positive int)
248
+ * }
249
+ *
250
+ * @example
251
+ * var ro = b.auth.jar.build(
252
+ * { response_type: "code", client_id: "s6BhdRkqt3",
253
+ * redirect_uri: "https://app/cb", scope: "openid", state: "xyz" },
254
+ * { clientId: "s6BhdRkqt3", audience: "https://as.example.com", key: clientKey, kid: "c1" });
255
+ * // → "eyJhbGciOiJFUzI1NiIsInR5cCI6Im9hdXRoLWF1dGh6LXJlcStqd3QiLCJraWQiOiJjMSJ9..."
256
+ */
257
+ function build(params, opts) {
258
+ if (params === null || typeof params !== "object" || Array.isArray(params)) {
259
+ throw new AuthJarError("auth-jar/bad-params",
260
+ "jar.build: params must be a plain object of authorization-request parameters");
261
+ }
262
+ validateOpts.requireObject(opts, "jar.build", AuthJarError, "auth-jar/bad-opts");
263
+ validateOpts(opts, ["clientId", "audience", "key", "alg", "kid", "expiresInMs"], "jar.build");
264
+ validateOpts.requireNonEmptyString(opts.clientId, "jar.build: clientId", AuthJarError, "auth-jar/bad-client-id");
265
+ validateOpts.requireNonEmptyString(opts.audience, "jar.build: audience", AuthJarError, "auth-jar/bad-audience");
266
+ if (opts.key === undefined || opts.key === null) {
267
+ throw new AuthJarError("auth-jar/no-key", "jar.build: key (the client's signing key) is required");
268
+ }
269
+ validateOpts.optionalNonEmptyString(opts.alg, "jar.build: alg", AuthJarError, "auth-jar/bad-alg");
270
+ validateOpts.optionalNonEmptyString(opts.kid, "jar.build: kid", AuthJarError, "auth-jar/bad-kid");
271
+ validateOpts.optionalPositiveInt(opts.expiresInMs, "jar.build: expiresInMs", AuthJarError, "auth-jar/bad-expiry");
272
+
273
+ // RFC 9101 §4 — a request object MUST NOT itself carry request /
274
+ // request_uri (recursion / confused-deputy vector). Refuse at build, the
275
+ // mirror of parse's anti-nesting check.
276
+ if (params.request !== undefined || params.request_uri !== undefined) {
277
+ throw new AuthJarError("auth-jar/nested-request",
278
+ "jar.build: params must not carry `request` or `request_uri` " +
279
+ "(RFC 9101 §4 — a request object cannot nest another)");
280
+ }
281
+ // The builder owns iss/aud and the JWT lifetime claims — a params key
282
+ // colliding with one would either shadow a builder-pinned binding (iss /
283
+ // aud) or fight the exp/nbf/jti the builder mints. Refuse so the operator
284
+ // routes lifetime through opts.expiresInMs and the identity bindings
285
+ // through clientId / audience.
286
+ for (var bi = 0; bi < BUILDER_OWNED_CLAIMS.length; bi += 1) {
287
+ var owned = BUILDER_OWNED_CLAIMS[bi];
288
+ if (Object.prototype.hasOwnProperty.call(params, owned)) {
289
+ throw new AuthJarError("auth-jar/reserved-claim",
290
+ "jar.build: params must not set the builder-owned claim '" + owned +
291
+ "' (iss/aud come from clientId/audience; exp/nbf/iat/jti are minted by the builder)");
292
+ }
293
+ }
294
+ // RFC 9101 §4 — response_type + client_id are REQUIRED request parameters.
295
+ for (var ri = 0; ri < REQUIRED_REQUEST_PARAMS.length; ri += 1) {
296
+ var req = REQUIRED_REQUEST_PARAMS[ri];
297
+ if (params[req] === undefined || params[req] === null || params[req] === "") {
298
+ throw new AuthJarError("auth-jar/missing-required-param",
299
+ "jar.build: params is missing the required '" + req + "' claim (RFC 9101 §4)");
300
+ }
301
+ }
302
+ // An explicit params.client_id MUST match opts.clientId — the iss the
303
+ // builder pins. A divergence is an operator mistake that would mint an
304
+ // object parse() then refuses on the client_id-mismatch path.
305
+ if (params.client_id !== opts.clientId) {
306
+ throw new AuthJarError("auth-jar/client-id-mismatch",
307
+ "jar.build: params.client_id ('" + params.client_id + "') must equal opts.clientId ('" +
308
+ opts.clientId + "')");
309
+ }
310
+
311
+ var nowSec = Math.floor(Date.now() / C.TIME.seconds(1));
312
+ var ttlMs = typeof opts.expiresInMs === "number" ? opts.expiresInMs : DEFAULT_JAR_EXPIRES_MS;
313
+ var claims = {
314
+ iss: opts.clientId, // RFC 9101 §5 — iss = client_id
315
+ aud: opts.audience, // RFC 9101 §5 — aud = AS issuer identifier
316
+ iat: nowSec,
317
+ nbf: nowSec,
318
+ exp: nowSec + Math.floor(ttlMs / C.TIME.seconds(1)), // FAPI-2 short window
319
+ jti: bCrypto.toBase64Url(bCrypto.generateBytes(16)), // single-use marker for the AS
320
+ };
321
+ // Every authorization-request parameter becomes a claim. Proto-pollution
322
+ // sentinels skipped; builder-owned claims passed as reserved so a stray
323
+ // collision (already refused above) can never shadow the minted set.
324
+ validateOpts.assignOwnEnumerable(claims, params, BUILDER_OWNED_CLAIMS);
325
+
326
+ // Sign through the classical-JWS primitive (b.auth.jws.sign). The typed
327
+ // header + alg-from-key + none-refusal all live there.
328
+ return jwtExternal.signExternal(claims, {
329
+ privateKey: opts.key,
330
+ alg: opts.alg,
331
+ kid: opts.kid,
332
+ typ: JAR_TYP,
333
+ });
334
+ }
335
+
175
336
  module.exports = {
176
337
  parse: parse,
338
+ build: build,
177
339
  JAR_TYP: JAR_TYP,
178
340
  AuthJarError: AuthJarError,
179
341
  };
@@ -109,6 +109,14 @@ function _b64urlDecode(s) {
109
109
  return Buffer.from(padded, "base64");
110
110
  }
111
111
 
112
+ // EC named-curve → the one ES* alg whose hash matches it (RFC 7518 §3.4).
113
+ // A P-256 key signs ES256 and only ES256; the curve fixes the hash, so a
114
+ // header alg of ES384 over a P-256 signature is self-inconsistent and a
115
+ // conforming verifier rejects it. Naming the binding here lets the signer
116
+ // derive the right header alg from the key instead of trusting a caller-
117
+ // supplied alg the key can't actually produce.
118
+ var _EC_CURVE_ALG = { prime256v1: "ES256", secp384r1: "ES384", secp521r1: "ES512" };
119
+
112
120
  function _verifyParamsForAlg(alg) {
113
121
  if (alg === "RS256") return { hash: "sha256", padding: nodeCrypto.constants.RSA_PKCS1_PADDING };
114
122
  if (alg === "RS384") return { hash: "sha384", padding: nodeCrypto.constants.RSA_PKCS1_PADDING };
@@ -124,6 +132,104 @@ function _verifyParamsForAlg(alg) {
124
132
  "alg '" + alg + "' is not supported by verifyExternal");
125
133
  }
126
134
 
135
+ // _toPrivateKey — import the operator's classical signing key from any of
136
+ // the three shapes node:crypto understands (KeyObject / PEM string|Buffer /
137
+ // private JWK). The PQC framework signer (lib/auth/jwt.js) never travels
138
+ // this path; this is the classical-interop importer only.
139
+ function _toPrivateKey(value, label) {
140
+ if (!value) {
141
+ throw new AuthError("auth-jwt-external/sign-no-key", label + ": privateKey is required");
142
+ }
143
+ if (value instanceof nodeCrypto.KeyObject) return value;
144
+ try {
145
+ if (typeof value === "string" || Buffer.isBuffer(value)) {
146
+ return nodeCrypto.createPrivateKey({ key: value, format: "pem" });
147
+ }
148
+ if (typeof value === "object" && value.kty) {
149
+ return nodeCrypto.createPrivateKey({ key: value, format: "jwk" });
150
+ }
151
+ } catch (e) {
152
+ throw new AuthError("auth-jwt-external/sign-bad-key",
153
+ label + ": private key parse failed: " + ((e && e.message) || String(e)));
154
+ }
155
+ throw new AuthError("auth-jwt-external/sign-bad-key",
156
+ label + ": privateKey must be a PEM string/Buffer, private JWK object, or KeyObject");
157
+ }
158
+
159
+ // _resolveSignAlg — derive the JWS `alg` for a private key, validating any
160
+ // explicit override against what the key can actually produce. A signer
161
+ // that emitted a fixed `alg` header while signing with an incompatible key
162
+ // (e.g. an `ES256` header over an Ed25519 signature) would mint a token no
163
+ // conforming verifier accepts; deriving the alg from the key — or refusing
164
+ // an incompatible explicit alg BEFORE signing — closes that self-invalid
165
+ // shape. RFC 7518 §3.1 maps each `alg` to the key type it requires.
166
+ function _resolveSignAlg(explicitAlg, privateKey, label) {
167
+ var kty = privateKey.asymmetricKeyType;
168
+ var defaultAlg, compatible;
169
+ if (kty === "ec") {
170
+ var curve = (privateKey.asymmetricKeyDetails && privateKey.asymmetricKeyDetails.namedCurve) || "";
171
+ defaultAlg = _EC_CURVE_ALG[curve];
172
+ if (!defaultAlg) {
173
+ throw new AuthError("auth-jwt-external/sign-key-unsupported",
174
+ label + ": EC curve '" + curve + "' has no JWS alg (use P-256 / P-384 / P-521)");
175
+ }
176
+ compatible = [defaultAlg]; // an EC curve pins exactly one ES alg
177
+ } else if (kty === "rsa") {
178
+ defaultAlg = "RS256";
179
+ compatible = ["RS256", "RS384", "RS512", "PS256", "PS384", "PS512"];
180
+ } else if (kty === "rsa-pss") {
181
+ defaultAlg = "PS256";
182
+ compatible = ["PS256", "PS384", "PS512"]; // an RSA-PSS key cannot produce an RS* signature
183
+ } else if (kty === "ed25519" || kty === "ed448") {
184
+ defaultAlg = "EdDSA";
185
+ compatible = ["EdDSA"];
186
+ } else {
187
+ throw new AuthError("auth-jwt-external/sign-key-unsupported",
188
+ label + ": key type '" + String(kty) + "' is not a supported JWS signing key (EC / RSA / Ed25519 / Ed448)");
189
+ }
190
+ if (explicitAlg === undefined || explicitAlg === null) return defaultAlg;
191
+ if (explicitAlg === "none" || REFUSED_ALGS.indexOf(explicitAlg) !== -1) {
192
+ throw new AuthError("auth-jwt-external/sign-alg-refused",
193
+ label + ": alg '" + explicitAlg + "' is refused (HMAC / none are never valid for an asymmetric signer)");
194
+ }
195
+ if (SUPPORTED_CLASSICAL_ALGS.indexOf(explicitAlg) === -1) {
196
+ throw new AuthError("auth-jwt-external/sign-alg-unsupported",
197
+ label + ": alg '" + explicitAlg + "' is not a supported classical JWS algorithm (" +
198
+ SUPPORTED_CLASSICAL_ALGS.join(", ") + ")");
199
+ }
200
+ if (compatible.indexOf(explicitAlg) === -1) {
201
+ throw new AuthError("auth-jwt-external/sign-alg-key-mismatch",
202
+ label + ": alg '" + explicitAlg + "' is incompatible with the " + kty +
203
+ " key (compatible: " + compatible.join(", ") + ")");
204
+ }
205
+ return explicitAlg;
206
+ }
207
+
208
+ // _signCompactJws — produce the compact JWS serialization (protected
209
+ // header . payload . signature) for an already-resolved alg + imported
210
+ // private key. Header and payload are JCS-independent here: they are
211
+ // serialized exactly once by the signer, base64url-encoded, and that byte
212
+ // string IS the signing input, so there is no canonicalization gap a
213
+ // verifier could diverge on.
214
+ function _signCompactJws(header, payload, privateKey, alg) {
215
+ var params = _verifyParamsForAlg(alg);
216
+ var headerB64 = bCrypto.toBase64Url(Buffer.from(JSON.stringify(header), "utf8"));
217
+ var payloadB64 = bCrypto.toBase64Url(Buffer.from(JSON.stringify(payload), "utf8"));
218
+ var signingInput = headerB64 + "." + payloadB64;
219
+ var input = Buffer.from(signingInput, "ascii");
220
+ var sig;
221
+ if (params.hash === null) {
222
+ sig = nodeCrypto.sign(null, input, privateKey); // EdDSA — no prehash
223
+ } else {
224
+ var keyParam = { key: privateKey };
225
+ if (params.padding !== undefined) keyParam.padding = params.padding;
226
+ if (params.saltLength !== undefined) keyParam.saltLength = params.saltLength;
227
+ if (params.dsaEncoding !== undefined) keyParam.dsaEncoding = params.dsaEncoding;
228
+ sig = nodeCrypto.sign(params.hash, input, keyParam);
229
+ }
230
+ return signingInput + "." + bCrypto.toBase64Url(sig);
231
+ }
232
+
127
233
  function _jwkToKey(jwk) {
128
234
  try { return nodeCrypto.createPublicKey({ key: jwk, format: "jwk" }); }
129
235
  catch (e) {
@@ -512,12 +618,119 @@ async function verifyExternal(token, opts) {
512
618
  return { header: header, claims: payload };
513
619
  }
514
620
 
621
+ /**
622
+ * @primitive b.auth.jws.sign
623
+ * @signature b.auth.jws.sign(claims, opts)
624
+ * @since 0.14.22
625
+ * @status stable
626
+ * @compliance soc2
627
+ * @related b.auth.jar.build, b.auth.jar.parse
628
+ *
629
+ * Mint a compact JWS (RFC 7515) over <code>claims</code> using a classical
630
+ * asymmetric algorithm — RS/PS256/384/512, ES256/384/512, or EdDSA. This
631
+ * primitive exists strictly for <strong>interop with external ecosystems</strong>:
632
+ * OAuth/OIDC OPs and RPs (and the wallet / FAPI profiles built on them)
633
+ * require a request object / assertion signed with a classical JWS alg, and
634
+ * the framework's own token signer (<code>b.auth.jwt.sign</code>) is
635
+ * PQC-only (ML-DSA / SLH-DSA). It is <strong>never the framework-internal
636
+ * token default</strong>; <code>lib/jwt.js</code> remains the signer for
637
+ * tokens blamejs itself issues. The verify counterpart is
638
+ * <code>b.auth.jwt.verifyExternal</code>; this is its inverse for the cases
639
+ * where blamejs is the client emitting a signed object to a third party.
640
+ *
641
+ * The signing <code>alg</code> is derived from the key type (RFC 7518 §3.1)
642
+ * so the header alg always matches the signature the key can actually
643
+ * produce; an explicit <code>opts.alg</code> is validated against the key
644
+ * and refused if incompatible. <code>alg: "none"</code> and HMAC algs are
645
+ * refused outright — an asymmetric signer never emits them. The protected
646
+ * header always carries <code>alg</code>; <code>typ</code> and <code>kid</code>
647
+ * are set from <code>opts</code> when supplied (callers minting a typed
648
+ * object such as a JAR request object pass <code>typ</code>). Extra
649
+ * <code>opts.header</code> members pass through with two refusals:
650
+ * <code>b64</code> (RFC 7797 unencoded payload — it changes the signing
651
+ * input, which this signer always base64url-encodes) and <code>crit</code>
652
+ * (RFC 7515 §4.1.11 — it promises extension semantics the signer does not
653
+ * implement). Emitting either would produce a JWS whose header claims
654
+ * semantics its signature was not computed under.
655
+ *
656
+ * @opts
657
+ * {
658
+ * privateKey: KeyObject|PEM|JWK, // required — classical signing key
659
+ * alg?: string, // override; default inferred from the key (RS256 / ES256/384/512 / PS256 / EdDSA)
660
+ * typ?: string, // protected-header typ (e.g. "oauth-authz-req+jwt")
661
+ * kid?: string, // protected-header kid (JWKS key selection)
662
+ * header?: object, // extra protected-header members (alg/typ/kid reserved; b64/crit refused)
663
+ * }
664
+ *
665
+ * @example
666
+ * var jws = b.auth.jws.sign(
667
+ * { iss: "client", aud: "https://as.example.com", response_type: "code" },
668
+ * { privateKey: clientKey, typ: "oauth-authz-req+jwt", kid: "c1" });
669
+ * // → "eyJhbGciOiJFUzI1NiIsInR5cCI6Im9hdXRoLWF1dGh6LXJlcStqd3QifQ..."
670
+ */
671
+ function signExternal(claims, opts) {
672
+ if (claims === null || typeof claims !== "object" || Array.isArray(claims)) {
673
+ throw new AuthError("auth-jwt-external/sign-bad-claims",
674
+ "jws.sign: claims must be a plain object");
675
+ }
676
+ validateOpts.requireObject(opts, "jws.sign", AuthError, "auth-jwt-external/sign-bad-opts");
677
+ validateOpts(opts, ["privateKey", "alg", "typ", "kid", "header"], "auth.jws.sign");
678
+ if (opts.alg !== undefined && opts.alg !== null) {
679
+ validateOpts.requireNonEmptyString(opts.alg, "jws.sign: alg", AuthError, "auth-jwt-external/sign-bad-alg");
680
+ }
681
+ if (opts.typ !== undefined && opts.typ !== null) {
682
+ validateOpts.requireNonEmptyString(opts.typ, "jws.sign: typ", AuthError, "auth-jwt-external/sign-bad-typ");
683
+ }
684
+ if (opts.kid !== undefined && opts.kid !== null) {
685
+ validateOpts.requireNonEmptyString(opts.kid, "jws.sign: kid", AuthError, "auth-jwt-external/sign-bad-kid");
686
+ }
687
+ validateOpts.optionalPlainObject(opts.header, "jws.sign: header", AuthError, "auth-jwt-external/sign-bad-header",
688
+ "must be a plain object of extra protected-header members");
689
+ // RFC 7797 `b64: false` changes the JWS signing input (the payload is
690
+ // signed raw, not base64url-encoded) and RFC 7515 §4.1.11 `crit`
691
+ // promises the producer implements every extension it names.
692
+ // _signCompactJws always base64url-encodes the payload and implements
693
+ // no header extensions, so passing either member through would mint a
694
+ // JWS whose header advertises semantics its signature was not computed
695
+ // under — a compliant verifier derives a different signing input (or
696
+ // refuses the critical header). Refused until those semantics are
697
+ // actually implemented.
698
+ if (opts.header !== undefined && opts.header !== null &&
699
+ (Object.prototype.hasOwnProperty.call(opts.header, "b64") ||
700
+ Object.prototype.hasOwnProperty.call(opts.header, "crit"))) {
701
+ throw new AuthError("auth-jwt-external/sign-unsupported-header",
702
+ "jws.sign: header members 'b64' (RFC 7797 unencoded payload) and 'crit' " +
703
+ "(RFC 7515 §4.1.11 critical extensions) are not supported — the signer " +
704
+ "always base64url-encodes the payload and implements no critical extensions");
705
+ }
706
+
707
+ var key = _toPrivateKey(opts.privateKey, "jws.sign");
708
+ var alg = _resolveSignAlg(opts.alg, key, "jws.sign");
709
+
710
+ // Extra protected-header members first (alg/typ/kid reserved so a
711
+ // caller-supplied header object can never override the signer-set alg —
712
+ // the canonical alg-substitution shape), then the reserved members.
713
+ var header = validateOpts.assignOwnEnumerable({}, opts.header, ["alg", "typ", "kid"]);
714
+ header.alg = alg;
715
+ if (opts.typ !== undefined && opts.typ !== null) header.typ = opts.typ;
716
+ if (opts.kid !== undefined && opts.kid !== null) header.kid = opts.kid;
717
+
718
+ return _signCompactJws(header, claims, key, alg);
719
+ }
720
+
515
721
  module.exports = {
516
722
  verifyExternal: verifyExternal,
723
+ signExternal: signExternal,
517
724
  SUPPORTED_CLASSICAL_ALGS: SUPPORTED_CLASSICAL_ALGS,
518
725
  REFUSED_ALGS: REFUSED_ALGS,
519
726
  // Shared JOSE defenses — routed from oauth.verifyIdToken /
520
727
  // oid4vci proof verify / sd-jwt-vc.verify / openid-federation.
521
728
  _assertAlgKtyMatch: _assertAlgKtyMatch,
522
729
  _issuerMatches: _issuerMatches,
730
+ // Classical-JWS signer internals — composed by oauth.js's attestation
731
+ // builders so the alg-from-key + compact-JWS bodies live in exactly one
732
+ // place (the classical-JOSE domain owner).
733
+ _toPrivateKey: _toPrivateKey,
734
+ _resolveSignAlg: _resolveSignAlg,
735
+ _signCompactJws: _signCompactJws,
523
736
  };
package/lib/auth/oauth.js CHANGED
@@ -122,6 +122,11 @@ var lazyRequire = require("../lazy-require");
122
122
  // convention §3; no circular load — jwt-external requires nothing from
123
123
  // oauth.
124
124
  var jwtExternal = require("./jwt-external");
125
+ // RFC 9101 request-object builder — composed by pushAuthorizationRequest
126
+ // when the operator opts into sending a signed request object. Top-of-file
127
+ // per convention §3; no circular load — jar requires jwt-external +
128
+ // validate-opts only, nothing from oauth.
129
+ var jar = require("./jar");
125
130
  var audit = lazyRequire(function () { return require("../audit"); });
126
131
 
127
132
  // Cap on responses parsed from upstream OAuth providers. Token /
@@ -517,97 +522,62 @@ var MAX_ATTESTATION_JWT_BYTES = C.BYTES.kib(16);
517
522
  var DEFAULT_POP_MAX_AGE_SEC = C.TIME.minutes(5) / C.TIME.seconds(1);
518
523
 
519
524
  // Sign/verify params keyed by alg — superset of _verifyParamsForAlg that
520
- // also covers EdDSA (used only on the attestation path; the ID-token
521
- // verifier keeps its own narrower table untouched).
525
+ // also covers EdDSA (used only on the attestation verify path; the
526
+ // ID-token verifier keeps its own narrower table untouched).
522
527
  function _attestationCryptoParams(alg) {
523
528
  if (alg === "EdDSA") return { hash: null };
524
529
  return _verifyParamsForAlg(alg);
525
530
  }
526
531
 
532
+ // _toAttestationPrivateKey / _resolveAttestationAlg / _signAttestationJws —
533
+ // thin wrappers over the classical-JWS signer that the jwt-external module
534
+ // owns (b.auth.jws.sign internals). The attestation path keeps its own
535
+ // `auth-oauth/attestation-*` error codes so operators routing alerts on
536
+ // that class see no change; the signer BODIES (alg-from-key derivation,
537
+ // compact-JWS assembly) live in exactly one place — the classical-JOSE
538
+ // domain owner — rather than duplicated here. RFC 7518 §3.1 alg↔key
539
+ // binding and the self-invalid-alg defenses are enforced by the composed
540
+ // primitive.
541
+
527
542
  function _toAttestationPrivateKey(value, label) {
528
- if (!value) {
529
- throw new OAuthError("auth-oauth/attestation-no-key", label + ": privateKey is required");
530
- }
531
- if (value instanceof nodeCrypto.KeyObject) return value;
532
- try {
533
- if (typeof value === "string" || Buffer.isBuffer(value)) {
534
- return nodeCrypto.createPrivateKey({ key: value, format: "pem" });
535
- }
536
- if (typeof value === "object" && value.kty) {
537
- return nodeCrypto.createPrivateKey({ key: value, format: "jwk" });
538
- }
539
- } catch (e) {
540
- throw new OAuthError("auth-oauth/attestation-bad-key",
541
- label + ": private key parse failed: " + ((e && e.message) || String(e)));
543
+ try { return jwtExternal._toPrivateKey(value, label); }
544
+ catch (e) {
545
+ var code = (e && e.code) === "auth-jwt-external/sign-no-key"
546
+ ? "auth-oauth/attestation-no-key" : "auth-oauth/attestation-bad-key";
547
+ throw new OAuthError(code, (e && e.message) || String(e));
542
548
  }
543
- throw new OAuthError("auth-oauth/attestation-bad-key",
544
- label + ": privateKey must be a PEM string/Buffer, JWK object, or KeyObject");
545
549
  }
546
550
 
547
- // EC curve → the one ES* alg whose hash matches it (RFC 7518 §3.4).
548
- var _EC_CURVE_ALG = { prime256v1: "ES256", secp384r1: "ES384", secp521r1: "ES512" };
549
-
550
551
  // Resolve the JWS alg for an attestation / PoP signature. When the caller
551
- // gives no `algorithm`, infer the default that matches the key type so a
552
- // non-EC attester key (RSA, Ed25519) yields a self-consistent JWS — header
553
- // alg ⇄ signature key — instead of a fixed `ES256` header signed with the
554
- // real key, which `verifyClientAttestation`'s alg/kty cross-check would
555
- // then reject. An explicit alg incompatible with the key is refused BEFORE
556
- // signing rather than producing a self-invalid attestation.
552
+ // gives no `algorithm`, the composed signer infers the default that matches
553
+ // the key type so a non-EC attester key (RSA, Ed25519) yields a
554
+ // self-consistent JWS — header alg ⇄ signature key — instead of a fixed
555
+ // `ES256` header signed with the real key, which `verifyClientAttestation`'s
556
+ // alg/kty cross-check would then reject. An explicit alg incompatible with
557
+ // the key is refused BEFORE signing. The draft additionally floors the
558
+ // accepted set to ATTESTATION_ALGS (no HMAC / none); the composed resolver
559
+ // already refuses those, surfaced here as the attestation-specific code.
557
560
  function _resolveAttestationAlg(explicitAlg, privateKey, label) {
558
- var kty = privateKey.asymmetricKeyType;
559
- var defaultAlg, compatible;
560
- if (kty === "ec") {
561
- var curve = (privateKey.asymmetricKeyDetails && privateKey.asymmetricKeyDetails.namedCurve) || "";
562
- defaultAlg = _EC_CURVE_ALG[curve];
563
- if (!defaultAlg) {
564
- throw new OAuthError("auth-oauth/attestation-key-unsupported",
565
- label + ": EC curve '" + curve + "' has no attestation JWS alg (use P-256 / P-384 / P-521)");
566
- }
567
- compatible = [defaultAlg]; // an EC curve pins exactly one ES alg
568
- } else if (kty === "rsa") {
569
- defaultAlg = "RS256";
570
- compatible = ["RS256", "RS384", "RS512", "PS256", "PS384", "PS512"];
571
- } else if (kty === "rsa-pss") {
572
- defaultAlg = "PS256";
573
- compatible = ["PS256", "PS384", "PS512"]; // an RSA-PSS key cannot produce an RS* signature
574
- } else if (kty === "ed25519" || kty === "ed448") {
575
- defaultAlg = "EdDSA";
576
- compatible = ["EdDSA"];
577
- } else {
578
- throw new OAuthError("auth-oauth/attestation-key-unsupported",
579
- label + ": key type '" + String(kty) + "' is not a supported attestation key (EC / RSA / Ed25519 / Ed448)");
580
- }
581
- if (explicitAlg === undefined || explicitAlg === null) return defaultAlg;
582
- if (ATTESTATION_ALGS.indexOf(explicitAlg) === -1) {
583
- throw new OAuthError("auth-oauth/attestation-alg-not-accepted",
584
- label + ": alg '" + explicitAlg + "' is not an accepted attestation algorithm");
585
- }
586
- if (compatible.indexOf(explicitAlg) === -1) {
587
- throw new OAuthError("auth-oauth/attestation-alg-key-mismatch",
588
- label + ": alg '" + explicitAlg + "' is incompatible with the " + kty +
589
- " key (compatible: " + compatible.join(", ") + ")");
561
+ try {
562
+ return jwtExternal._resolveSignAlg(explicitAlg, privateKey, label);
563
+ } catch (e) {
564
+ var ec = (e && e.code) || "";
565
+ if (ec === "auth-jwt-external/sign-alg-key-mismatch") {
566
+ throw new OAuthError("auth-oauth/attestation-alg-key-mismatch", (e && e.message) || String(e));
567
+ }
568
+ if (ec === "auth-jwt-external/sign-alg-refused" || ec === "auth-jwt-external/sign-alg-unsupported") {
569
+ throw new OAuthError("auth-oauth/attestation-alg-not-accepted",
570
+ label + ": alg '" + explicitAlg + "' is not an accepted attestation algorithm");
571
+ }
572
+ if (ec === "auth-jwt-external/sign-key-unsupported") {
573
+ throw new OAuthError("auth-oauth/attestation-key-unsupported", (e && e.message) || String(e));
574
+ }
575
+ throw new OAuthError("auth-oauth/attestation-bad-key", (e && e.message) || String(e));
590
576
  }
591
- return explicitAlg;
592
577
  }
593
578
 
594
579
  function _signAttestationJws(header, payload, privateKey, alg) {
595
- var params = _attestationCryptoParams(alg);
596
- var headerB64 = _b64urlEncode(Buffer.from(JSON.stringify(header), "utf8"));
597
- var payloadB64 = _b64urlEncode(Buffer.from(JSON.stringify(payload), "utf8"));
598
- var signingInput = headerB64 + "." + payloadB64;
599
- var sig;
600
- var input = Buffer.from(signingInput, "ascii");
601
- if (params.hash === null) {
602
- sig = nodeCrypto.sign(null, input, privateKey); // EdDSA — no prehash
603
- } else {
604
- var keyParam = { key: privateKey };
605
- if (params.padding !== undefined) keyParam.padding = params.padding;
606
- if (params.saltLength !== undefined) keyParam.saltLength = params.saltLength;
607
- if (params.dsaEncoding !== undefined) keyParam.dsaEncoding = params.dsaEncoding;
608
- sig = nodeCrypto.sign(params.hash, input, keyParam);
609
- }
610
- return signingInput + "." + _b64urlEncode(sig);
580
+ return jwtExternal._signCompactJws(header, payload, privateKey, alg);
611
581
  }
612
582
 
613
583
  // Verify a compact JWS against an already-imported public KeyObject. The
@@ -751,16 +721,9 @@ function buildClientAttestation(aopts) {
751
721
  };
752
722
  if (typeof aopts.nbf === "number") payload.nbf = aopts.nbf;
753
723
  // Operator extra claims merged WITHOUT overriding the spec-required
754
- // fields (no prototype-pollution: only own enumerable keys, reserved
755
- // names rejected).
724
+ // fields (proto-pollution sentinels skipped, the spec keys reserved).
756
725
  if (aopts.extraClaims && typeof aopts.extraClaims === "object" && !Array.isArray(aopts.extraClaims)) {
757
- var ck = Object.keys(aopts.extraClaims);
758
- for (var i = 0; i < ck.length; i += 1) {
759
- var k = ck[i];
760
- if (k === "__proto__" || k === "constructor" || k === "prototype") continue;
761
- if (Object.prototype.hasOwnProperty.call(payload, k)) continue; // never override spec fields
762
- payload[k] = aopts.extraClaims[k];
763
- }
726
+ validateOpts.assignOwnEnumerable(payload, aopts.extraClaims, Object.keys(payload));
764
727
  }
765
728
  return _signAttestationJws(
766
729
  { typ: "oauth-client-attestation+jwt", alg: alg }, payload, key, alg);
@@ -1996,6 +1959,13 @@ function create(opts) {
1996
1959
  // browser-side redirect to /authorize. Defends against parameter
1997
1960
  // tampering by an MITM at the user-agent + against URL-length
1998
1961
  // overflow on long authorization requests.
1962
+ //
1963
+ // RFC 9101 signed request object: pass `signedRequestObject: { key,
1964
+ // alg?, kid?, audience?, expiresInMs? }` to push a JAR request object
1965
+ // instead of plain form params. The authorization parameters then
1966
+ // travel as signed claims (RFC 9126 §3 — form body carries only
1967
+ // `request` + client auth), so the PAR endpoint can verify they
1968
+ // arrived exactly as the client signed them. Absent → plain-form PAR.
1999
1969
  async function pushAuthorizationRequest(uopts) {
2000
1970
  uopts = uopts || {};
2001
1971
  var endpoint;
@@ -2006,6 +1976,20 @@ function create(opts) {
2006
1976
  "pushed_authorization_request_endpoint (set opts.pushedAuthorizationRequestEndpoint " +
2007
1977
  "on create() if the IdP doesn't publish it)");
2008
1978
  }
1979
+ // RFC 9101 signed-request-object opt: when the operator supplies
1980
+ // `signedRequestObject` (a config object carrying the client's signing
1981
+ // key), the authorization parameters travel as claims of a JAR request
1982
+ // object rather than as bare form params. Validated config-time; absent
1983
+ // → the existing plain-form path sends the same key/value set
1984
+ // (form-encoded params are unordered per the media type).
1985
+ var sro = uopts.signedRequestObject || null;
1986
+ if (sro) {
1987
+ validateOpts.optionalPlainObject(sro, "pushAuthorizationRequest: signedRequestObject",
1988
+ OAuthError, "auth-oauth/par-bad-request-object-opt",
1989
+ "must be an object { key, alg?, kid?, audience?, expiresInMs? }");
1990
+ validateOpts(sro, ["key", "alg", "kid", "audience", "expiresInMs"],
1991
+ "pushAuthorizationRequest.signedRequestObject");
1992
+ }
2009
1993
  // Same PKCE-downgrade gate as authorizationUrl (RFC 9700 §4.13):
2010
1994
  // PAR pushes the identical S256 challenge, so an OP advertising
2011
1995
  // code_challenge_methods_supported without S256 is refused here too.
@@ -2015,31 +1999,60 @@ function create(opts) {
2015
1999
  var state = uopts.state || _generateRandomToken(STATE_NONCE_BYTES);
2016
2000
  var nonce = uopts.nonce || (isOidc ? _generateRandomToken(STATE_NONCE_BYTES) : null);
2017
2001
  var pkceVals = _generatePkce();
2018
- var body = new URLSearchParams();
2019
- body.set("response_type", "code");
2020
- body.set("client_id", clientId);
2021
- body.set("redirect_uri", redirectUri);
2022
- body.set("scope", scope.join(" "));
2023
- body.set("state", state);
2024
- if (nonce) body.set("nonce", nonce);
2025
- body.set("code_challenge", pkceVals.challenge);
2026
- body.set("code_challenge_method", "S256");
2027
- if (responseMode) body.set("response_mode", responseMode);
2028
- if (uopts.prompt) body.set("prompt", uopts.prompt);
2029
- if (uopts.loginHint) body.set("login_hint", uopts.loginHint);
2030
- if (uopts.maxAge != null) body.set("max_age", String(uopts.maxAge));
2031
- if (clientSecret) body.set("client_secret", clientSecret);
2002
+ // The authorization-request parameters. On the plain path these are set
2003
+ // on the form body directly; on the JAR path they become request-object
2004
+ // claims and the form body carries only `request` + client auth.
2005
+ var authzParams = {
2006
+ response_type: "code",
2007
+ client_id: clientId,
2008
+ redirect_uri: redirectUri,
2009
+ scope: scope.join(" "),
2010
+ state: state,
2011
+ code_challenge: pkceVals.challenge,
2012
+ code_challenge_method: "S256",
2013
+ };
2014
+ if (nonce) authzParams.nonce = nonce;
2015
+ if (responseMode) authzParams.response_mode = responseMode;
2016
+ if (uopts.prompt) authzParams.prompt = uopts.prompt;
2017
+ if (uopts.loginHint) authzParams.login_hint = uopts.loginHint;
2018
+ if (uopts.maxAge != null) authzParams.max_age = String(uopts.maxAge);
2032
2019
  // RFC 9396 — push the fine-grained authorization request through PAR
2033
2020
  // identically to the redirect path (validated, then JSON-serialized).
2034
2021
  var requestedAuthzDetails = null;
2035
2022
  if (uopts.authorizationDetails !== undefined) {
2036
2023
  requestedAuthzDetails = _validateAuthorizationDetailsArray(
2037
2024
  uopts.authorizationDetails, "pushAuthorizationRequest");
2038
- body.set("authorization_details", JSON.stringify(requestedAuthzDetails));
2025
+ authzParams.authorization_details = JSON.stringify(requestedAuthzDetails);
2039
2026
  }
2040
2027
  if (uopts.extraParams && typeof uopts.extraParams === "object") {
2041
2028
  var ek = Object.keys(uopts.extraParams);
2042
- for (var i = 0; i < ek.length; i++) body.set(ek[i], String(uopts.extraParams[ek[i]]));
2029
+ for (var i = 0; i < ek.length; i++) authzParams[ek[i]] = String(uopts.extraParams[ek[i]]);
2030
+ }
2031
+
2032
+ var body = new URLSearchParams();
2033
+ if (sro) {
2034
+ // RFC 9126 §3 — when a signed request object is pushed, the
2035
+ // authorization parameters MUST appear ONLY as claims of the JWT;
2036
+ // the form body carries `request` plus the parameters a client
2037
+ // authentication method requires (client_id, and client_secret for
2038
+ // the secret-based methods) and nothing else. The JAR `aud` is the
2039
+ // AS issuer identifier (RFC 9101 §5) — the operator may override but
2040
+ // it defaults to the configured `issuer`.
2041
+ var requestJwt = jar.build(authzParams, {
2042
+ clientId: clientId,
2043
+ audience: sro.audience || issuer,
2044
+ key: sro.key,
2045
+ alg: sro.alg,
2046
+ kid: sro.kid,
2047
+ expiresInMs: sro.expiresInMs,
2048
+ });
2049
+ body.set("request", requestJwt);
2050
+ body.set("client_id", clientId); // RFC 9126 §3 — client identification
2051
+ if (clientSecret) body.set("client_secret", clientSecret);
2052
+ } else {
2053
+ var ak = Object.keys(authzParams);
2054
+ for (var ap = 0; ap < ak.length; ap++) body.set(ak[ap], authzParams[ak[ap]]);
2055
+ if (clientSecret) body.set("client_secret", clientSecret);
2043
2056
  }
2044
2057
  var rv = await _postForm(endpoint, body);
2045
2058
  if (!rv || typeof rv.request_uri !== "string" || rv.request_uri.length === 0) {
@@ -2062,6 +2075,7 @@ function create(opts) {
2062
2075
  requestUri: rv.request_uri,
2063
2076
  expiresIn: typeof rv.expires_in === "number" ? rv.expires_in : null,
2064
2077
  authorizationDetails: requestedAuthzDetails,
2078
+ requestObjectSent: !!sro,
2065
2079
  };
2066
2080
  }
2067
2081
 
@@ -673,13 +673,12 @@ function _buildMultipartBody(spec) {
673
673
  var SENSITIVE_HEADERS_LC = ["authorization", "cookie", "proxy-authorization"];
674
674
 
675
675
  function _stripCrossOriginAuth(headers) {
676
- var out = {};
677
676
  var keys = Object.keys(headers);
677
+ var strip = [];
678
678
  for (var i = 0; i < keys.length; i++) {
679
- if (SENSITIVE_HEADERS_LC.indexOf(keys[i].toLowerCase()) !== -1) continue;
680
- out[keys[i]] = headers[keys[i]];
679
+ if (SENSITIVE_HEADERS_LC.indexOf(keys[i].toLowerCase()) !== -1) strip.push(keys[i]);
681
680
  }
682
- return out;
681
+ return validateOpts.assignOwnEnumerable({}, headers, strip);
683
682
  }
684
683
 
685
684
  /**
package/lib/lro.js CHANGED
@@ -185,13 +185,12 @@ function create(opts) {
185
185
  }
186
186
 
187
187
  function _stripPrivate(op) {
188
- var out = {};
189
188
  var keys = Object.keys(op);
189
+ var priv = [];
190
190
  for (var i = 0; i < keys.length; i += 1) {
191
- if (keys[i].charAt(0) === "_") continue;
192
- out[keys[i]] = op[keys[i]];
191
+ if (keys[i].charAt(0) === "_") priv.push(keys[i]);
193
192
  }
194
- return out;
193
+ return validateOpts.assignOwnEnumerable({}, op, priv);
195
194
  }
196
195
 
197
196
  module.exports = {
@@ -34,18 +34,10 @@
34
34
  */
35
35
 
36
36
  var problemDetails = require("../problem-details");
37
+ var validateOpts = require("../validate-opts");
37
38
 
38
39
  function _isFn(x) { return typeof x === "function"; }
39
40
 
40
- function _mergeInto(target, extra) {
41
- if (!extra || typeof extra !== "object") return target;
42
- var keys = Object.keys(extra);
43
- for (var i = 0; i < keys.length; i += 1) {
44
- target[keys[i]] = extra[keys[i]];
45
- }
46
- return target;
47
- }
48
-
49
41
  /**
50
42
  * Resolve a deny-path refusal through the uniform hook / problem+json
51
43
  * / default chain. Returns whatever the `onDeny` hook returns when it
@@ -144,7 +136,7 @@ function denyResponse(req, res, ctx) {
144
136
  return undefined;
145
137
  }
146
138
 
147
- var head = _mergeInto({ "Content-Type": ctx.contentType }, extra);
139
+ var head = validateOpts.assignOwnEnumerable({ "Content-Type": ctx.contentType }, extra);
148
140
  var denyOut = (ctx.body === undefined || ctx.body === null) ? ""
149
141
  : (typeof ctx.body === "string" ? ctx.body : JSON.stringify(ctx.body));
150
142
  if (ctx.body !== undefined && ctx.body !== null && req && typeof req.apiEncryptEncode === "function") {
@@ -317,10 +317,7 @@ function create(opts) {
317
317
  var entry = { ok: r.ok, ms: r.ms };
318
318
  if (r.detail) {
319
319
  // Merge detail keys other than `ok` into the entry.
320
- var keys = Object.keys(r.detail);
321
- for (var n = 0; n < keys.length; n++) {
322
- if (keys[n] !== "ok") entry[keys[n]] = r.detail[keys[n]];
323
- }
320
+ validateOpts.assignOwnEnumerable(entry, r.detail, ["ok"]);
324
321
  }
325
322
  if (r.error) entry.error = r.error;
326
323
  if (!r.critical) entry.critical = false;
@@ -52,13 +52,10 @@ function _baggageToObject(entries) {
52
52
 
53
53
  function _wrapLogger(baseLogger, req, opts) {
54
54
  if (!baseLogger || typeof baseLogger !== "object") return baseLogger;
55
- var wrapped = Object.create(null);
56
55
  // Preserve any non-level properties the operator put on the
57
- // logger (e.g. boot context, child-logger metadata).
58
- var keys = Object.keys(baseLogger);
59
- for (var i = 0; i < keys.length; i++) {
60
- if (LOG_LEVELS.indexOf(keys[i]) === -1) wrapped[keys[i]] = baseLogger[keys[i]];
61
- }
56
+ // logger (e.g. boot context, child-logger metadata); the level
57
+ // methods themselves are re-wrapped below.
58
+ var wrapped = validateOpts.assignOwnEnumerable(Object.create(null), baseLogger, LOG_LEVELS);
62
59
 
63
60
  function _enrichMeta(meta) {
64
61
  var enriched = Object.assign({}, meta || {});
@@ -393,6 +393,39 @@ function makeNamespacedEmitters(prefix, deps) {
393
393
  return { audit: audit, metric: metric };
394
394
  }
395
395
 
396
+ // assignOwnEnumerable — copy a source object's own enumerable keys onto a
397
+ // target, skipping the prototype-pollution sentinels (__proto__ /
398
+ // constructor / prototype) and any caller-named reserved keys. Several
399
+ // primitives that merge operator-supplied free-form fields onto a
400
+ // spec-built object (JOSE claim sets, JWS protected headers, attestation
401
+ // extra-claims) previously open-coded the identical
402
+ // `for (k of Object.keys(src)) { if (sentinel) continue; if (reserved)
403
+ // continue; dst[k] = src[k]; }` loop. Centralizing the proto-safe walk
404
+ // keeps the merge contract in one place. Reserved keys win — they are NOT
405
+ // overwritten — so the caller's spec-built fields can never be shadowed by
406
+ // a same-named operator key. Returns the target.
407
+ function assignOwnEnumerable(target, source, reservedKeys) {
408
+ if (!source || typeof source !== "object") return target;
409
+ var reserved = Object.create(null);
410
+ if (reservedKeys) for (var r = 0; r < reservedKeys.length; r += 1) reserved[reservedKeys[r]] = true;
411
+ var keys = Object.keys(source);
412
+ var entries = [];
413
+ for (var i = 0; i < keys.length; i += 1) {
414
+ var k = keys[i];
415
+ if (k === "__proto__" || k === "constructor" || k === "prototype") continue;
416
+ if (reserved[k]) continue;
417
+ entries.push([k, source[k]]);
418
+ }
419
+ // Staged through entries + Object.assign so the copy contains no
420
+ // computed-name property write at all: Object.fromEntries creates own
421
+ // data properties (it cannot walk the prototype chain), and the
422
+ // sentinel skip above means the staging object carries no
423
+ // __proto__/constructor/prototype key for Object.assign's [[Set]] to
424
+ // trip over. Same observable result as a key-by-key copy, with the
425
+ // arbitrary-property-write shape removed instead of merely guarded.
426
+ return Object.assign(target, Object.fromEntries(entries));
427
+ }
428
+
396
429
  // observabilityShape — operator-supplied `opts.observability` must
397
430
  // expose an `event` function. Parallel to auditShape; the n=1 catalog
398
431
  // tracks both inline-shape regexes.
@@ -426,3 +459,4 @@ module.exports.requireMethods = requireMethods;
426
459
  module.exports.applyDefaults = applyDefaults;
427
460
  module.exports.makeAuditEmitter = makeAuditEmitter;
428
461
  module.exports.makeNamespacedEmitters = makeNamespacedEmitters;
462
+ module.exports.assignOwnEnumerable = assignOwnEnumerable;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.14.21",
3
+ "version": "0.14.22",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:37cb0e0e-7cba-440b-89c3-febfeb9f7eef",
5
+ "serialNumber": "urn:uuid:6a454186-ef0a-43dd-8780-6900d4f1daf5",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-06-05T04:48:42.555Z",
8
+ "timestamp": "2026-06-05T17:23:40.587Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.14.21",
22
+ "bom-ref": "@blamejs/core@0.14.22",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.14.21",
25
+ "version": "0.14.22",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.14.21",
29
+ "purl": "pkg:npm/%40blamejs/core@0.14.22",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.14.21",
57
+ "ref": "@blamejs/core@0.14.22",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]