@blamejs/core 0.18.7 → 0.18.9

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,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.18.x
10
10
 
11
+ - v0.18.9 (2026-08-02) — **Broader error- and edge-path test coverage for the CLI, mail, and HTTP-client primitives.** Error, edge, and fallback branches of b.cli, b.mail, and b.httpClient are now under explicit test. No behaviour changes: exercising every path confirmed each already behaves per its documented contract. One dead internal parameter on the HTTP-client cache path — never supplied by any caller — was removed. **Changed:** *Error- and edge-path tests for the CLI, mail, and HTTP-client primitives* — b.cli (the api-snapshot capture/compare load-error paths and the restore-apply round trip), b.mail (reverse-DNS forward-confirmation and failure fallbacks, SMTP UTF-8 and binary-MIME detection on edge inputs, AUTH LOGIN with an empty password, and audit-emit failures being swallowed without masking the delivery outcome), and b.httpClient (the HTTPS-connect failure classification and the HTTP/2 synchronous-request-throw path) now have explicit tests for their boundary branches. Behaviour is unchanged.
12
+
13
+ - v0.18.8 (2026-08-02) — **SSRF-safe local-transport HTTP, timestamped-HMAC webhook verification, the OAuth client-credentials grant, trusted-proxy identity headers, and a public keyed-HMAC primitive; plus a fix for a filterless overflow update that could rewrite every row.** Five general-purpose primitives land: talking HTTP to a co-located daemon over a socket the SSRF gate cannot protect, verifying the single-header timestamped-HMAC webhook scheme, the machine-to-machine OAuth client_credentials grant with a cached auto-renewing token manager, and peer-gated trust of a reverse proxy's identity headers. b.crypto.hmac exposes the framework's keyed HMAC (SHA3-512 by default; weaker digests available explicitly for external-scheme interop) and replaces b.crypto.hmacSha3. Separately, an update() on a collection whose field lives in the overflow store, called with no filter, no longer slips past the unconditional-write guard and silently rewrites every row. **Added:** *b.localHttp — SSRF-safe HTTP over a local transport* — b.localHttp.create({ socketPath }) and b.localHttp.request() speak HTTP to a co-located daemon over a Unix domain socket, a Windows named pipe, or a loopback TCP peer with a bearer token — the transports an SSRF gate cannot protect, because there is no network host to steer. It deliberately does not route through b.httpClient (DNS + the SSRF gate): a socket-path request cannot be pointed at an arbitrary IP, so that surface is exactly what it exists to bypass safely. It refuses any non-loopback TCP host, never forwards Origin or Referer to the daemon, bounds the response body, and sets the caller's Host header verbatim. Use it for a local control-plane API — a sidecar's endpoint, a container-runtime socket, a metrics port. · *b.webhookHmac.verify — timestamped-HMAC webhook verification* — Verifies the single-header timestamped-HMAC webhook scheme — t=<unix-seconds>,v1=<hmac-hex> signed over <timestamp>.<raw-body> — used by Stripe, Tailscale, and others. It refuses a timestamp outside the tolerance window (replay defense), checks every v1 value so a rotated signing secret verifies with no downtime, compares in constant time, and ignores signature versions it does not recognize. Pass the exact bytes received, not a parsed-then-re-serialized JSON body, or the HMAC will not reproduce. This is distinct from b.standardWebhooks, which verifies the three-header StandardWebhooks scheme. · *b.auth.oauth.clientCredentials + clientCredentialsManager — the client_credentials grant* — The RFC 6749 §4.4 client_credentials grant for machine-to-machine authentication with no user present: clientCredentials() POSTs grant_type=client_credentials and returns the access token — never a refresh token, per the RFC. It authenticates with the client id/secret via client_secret_post (default) or client_secret_basic (tokenEndpointAuthMethod), sends no scope unless you supply one (the authorization-code scope default is not applied to a machine-to-machine request), and a client_credentials-only client needs no redirectUri at create. clientCredentialsManager() wraps it in an in-memory cache that fetches on first use, re-fetches a configurable window before expiry, collapses concurrent callers onto one in-flight request, and — throughout a 429 backoff window (honoring the server's Retry-After when present) — serves the still-valid cached token or fails fast without another network call, rather than hammering the authorization server. The client's tokenEndpointAuthMethod (client_secret_post / client_secret_basic) applies uniformly to every token-endpoint request, not just this grant. · *b.requestHelpers.trustedIdentityHeaders — peer-gated proxy identity headers* — Resolves an identity-injecting reverse proxy's header family — Cloudflare Access Cf-Access-*, oauth2-proxy X-Forwarded-User, Tailscale Serve Tailscale-User-*, or any custom mapping — under the same trusted-proxy peer-gate as b.requestHelpers.trustedClientIp. It is the identity-header mirror of the X-Forwarded-For discipline: the family is trusted only when the immediate socket peer is a configured trusted proxy; from any other peer the headers are stripped from req.headers so downstream code cannot read a forged identity. With no gate configured it fails closed and always strips. A naive trust of these headers is a full impersonation bypass; this closes it by construction rather than opening a second, looser trust path. · *b.crypto.hmac — a public keyed-HMAC primitive* — b.crypto.hmac(key, data, algorithm = "sha3-512") exposes the framework's keyed HMAC with a PQC-first SHA3-512 default. Weaker digests (sha256, sha384, sha512, sha3-256, sha3-384) are available by naming them explicitly, for interop with an external scheme that pins one — a provider's SHA-256 webhook signature, for example; SHA-1 and MD5 are refused. Key and data accept a Buffer or a string; the result is a lowercase hex digest. **Removed:** *b.crypto.hmacSha3 (folded into b.crypto.hmac)* — b.crypto.hmacSha3 is removed. b.crypto.hmac already defaults to SHA3-512, so replace b.crypto.hmacSha3(key, data) with b.crypto.hmac(key, data) — the same algorithm and the same output. **Fixed:** *A filterless update on an overflow-backed collection no longer rewrites every row* — An update() on a collection whose target field lives in the overflow (JSON) store, called with no filter conditions — an empty or absent query — slipped past the unconditional-write guard that protects real-column updates and silently rewrote every row: the internal per-row writes each carry a WHERE by row id, so the guard never saw a match-everything update. Such an update is now refused with the same "call where(...) first" error as a real-column update. Provide an explicit filter for the rows you intend to change. **Detectors:** *Overflow-store update re-asserts the unconditional-write refusal* — A source guard keeps the overflow-store update path re-checking, on its read query, that a filter is present — so a filterless overflow update cannot regress to silently rewriting every row.
14
+
11
15
  - v0.18.7 (2026-08-02) — **STARTTLS idle-timeout no longer injects plaintext into the encrypted channel; broader mail-server test coverage.** A STARTTLS upgrade on the POP3 / IMAP / ManageSieve / SMTP-submission / MX servers left the pre-upgrade plain-socket idle-timeout handler armed, so a post-handshake idle wrote a plaintext timeout reply into the now-encrypted stream (the peer saw a TLS decode error / reset). The shared socket-upgrade path now strips that handler so the idle timeout is delivered encrypted. Alongside the fix, error and edge branches of b.mail.server.pop3, b.mail.server.imap, and b.mail.auth are now under test. Except for the fix, behaviour is unchanged. **Changed:** *Error- and edge-path tests for the POP3, IMAP, and mail-authentication primitives* — b.mail.server.pop3 (the STLS upgrade and post-handshake idle timeout, pipelined-command draining, AUTH / APOP / USER-PASS branches, and store-fallback and tenant-refusal shapes), b.mail.server.imap (APPEND / CATENATE literal handling, command-dispatch and guard-error branches, and the LOGIN / NOTIFY paths), and b.mail.auth (SPF macro expansion and record parsing, iprev forward-confirm, and dual-CIDR a/mx parsing) now have explicit tests for their boundary paths. **Security:** *STARTTLS idle timeout is delivered over the encrypted channel, not injected as plaintext* — The POP3, IMAP, ManageSieve, SMTP-submission, and MX servers arm a plain-socket idle timer whose handler writes a plaintext idle-timeout reply and closes the connection. After a STARTTLS upgrade the shared socket-upgrade path stripped only the plain socket's data listeners, so that plaintext handler survived the wrap and, on a post-handshake idle, injected cleartext into the now-encrypted stream — the peer observed a TLS decode error or connection reset instead of a clean encrypted timeout reply. The upgrade path now strips the plain socket's timeout listeners and disarms its idle timer, so the upgraded TLS socket owns the idle timeout and replies over the encrypted channel. **Detectors:** *STARTTLS upgrade strips the plaintext idle-timeout handler* — A source guard keeps the shared socket-upgrade path stripping the plain socket's timeout listeners alongside its data listeners, so a plaintext idle-timeout reply cannot survive into the encrypted channel.
12
16
 
13
17
  - v0.18.6 (2026-08-01) — **Mail SMTPUTF8 + credential-validation fixes and a static-quota fail-open fix, with broad error- and edge-path test coverage.** b.mail no longer forces SMTPUTF8 on a long-but-pure-ASCII subject or address (so such a message delivers to a peer that does not advertise SMTPUTF8), and now rejects a non-string credential/identity option at config time instead of crashing mid-send. b.static enforces its per-actor concurrency and bandwidth quotas even when a proxy blanks the client IP, closing a fail-open. Alongside the fixes, error, edge, and adversarial branches of the blamejs CLI, b.mail, b.mail.bimi, and b.static are now under test. Except for the noted fixes, behaviour is unchanged. **Changed:** *restore rollback names the resolved target in its missing-path error* — b.restoreRollback.rollback's missing-target error now names the resolved rollback path, so an operator — and the CLI's relative `--rollback` resolution — can see exactly which path was checked and found missing. · *Error- and edge-path tests for the CLI, mail sender, BIMI verifier, and static-file server* — The blamejs CLI (migrate/seed runner coded-error paths, the dev runner's --watch / --ignore / --kill-signal handling and start-rejection exit, restore inspect/apply/rollback and list-rollbacks, backup inspect/verify, mtls show-cert / status, vault status, and config-drift inspect on a tampered sidecar), b.mail (SMTPUTF8 handling, reverse-DNS forward-confirm, address and EAI-domain validation edges, the outbound SMTP-smuggling refusal, DATA dot-stuffing transparency, the send-audit recipient counts, DKIM-sign and interpret error fallbacks, the console / http / resend transports and their option defaults, guardDomain profile defaults and cc/bcc validation, and the CAN-SPAM refusal audit), b.mail.bimi's verification branches (including the empty-canonical-domain SAN-authorization backstop), and b.static's request, path-resolution, and quota-enforcement branches (including concurrency-slot release on a mid-stream client abort) now have explicit tests for their boundary paths. **Fixed:** *A long pure-ASCII subject or address no longer forces SMTPUTF8* — b.mail treated any subject or address longer than 254 characters as non-ASCII content, so an otherwise-deliverable pure-ASCII message required SMTPUTF8 (RFC 6531) and was refused with `eai-required-not-supported` whenever the receiving server did not advertise SMTPUTF8. The requirement is now decided solely by whether a field carries a non-ASCII code point, independent of length: long ASCII subjects and addresses deliver to any peer, and genuinely non-ASCII content still opts into SMTPUTF8 where the peer advertises it. · *Non-string SMTP transport options are rejected at config time* — b.mail.transports.smtp accepted a present-but-non-string user, pass, host, servername, or ehloName at build, then failed later on the wire — a numeric user, for example, reached `Buffer.from` at AUTH and threw, failing the send instead of authenticating. Such a value is now rejected at config time with `mail/smtp-misconfigured`, so a misconfiguration fails fast at boot rather than at first send. **Security:** *A blank client IP no longer disables b.static per-actor quotas* — The shared request-context reader let an empty req.ip shadow the socket-address fallback, yielding a null actor key that silently skipped b.static's per-actor concurrency and bandwidth caps (fail-open) — for example behind a proxy that blanks req.ip when it can't resolve a forwarded address. A blank req.ip now falls through to the real peer address, so the quotas are enforced. **Detectors:** *SMTPUTF8 requirement stays content-based, not length-bounded* — A source guard keeps b.mail's SMTPUTF8 decision keyed on the presence of a non-ASCII code point rather than the length-bounded ASCII validity check, so the long-ASCII-subject deliverability regression cannot return. · *SMTP field validation fails closed on a non-string value* — A source guard keeps b.mail's SMTP identity/credential field check rejecting a present non-string value at config time rather than passing it through to crash on the wire. · *A blank req.ip must not shadow the socket-address fallback* — A source guard keeps the request-context reader falling through to the socket address on an empty req.ip, so per-actor quotas that key off the resolved IP cannot be silently disabled.
package/README.md CHANGED
@@ -79,6 +79,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
79
79
  - OAuth 2.0 JARM signed-response decode (`parseJarmResponse`)
80
80
  - RFC 9101 JWT-Secured Authorization Request verification — server-side request-object parse with mandatory alg allowlist + iss/client_id/aud binding + anti-nesting (`b.auth.jar.parse`)
81
81
  - One-time-use refresh-token rotation with operator-supplied replay-defense callback (RFC 9700 §4.13 / OAuth 2.1 §6.1 — `refreshAccessToken({ seen })`)
82
+ - RFC 6749 §4.4 `client_credentials` grant for machine-to-machine auth, with a cached auto-renewing token manager — single-flight fetch, re-fetch before expiry, ride out a 429 on the cached token (`clientCredentials` / `clientCredentialsManager`)
82
83
  - **Federation / VC** — CIBA Core 1.0 (`b.auth.ciba`, poll/ping/push); OpenID Federation 1.0 trust chain + metadata_policy (`b.auth.openidFederation`); SAML 2.0 SP with XMLDSig signature-wrapping defense + RFC 9525 server-identity (`b.auth.saml`); OpenID4VCI 1.0 issuer (`b.auth.oid4vci`); OpenID4VP 1.0 verifier with DCQL (`b.auth.oid4vp`); SD-JWT VC with `key_attestation` extension (`b.auth.sdJwtVc`)
83
84
  - **Sessions** — `b.session`
84
85
  - PQC-sealed sid cookie (ML-KEM-1024 + P-384 hybrid + XChaCha20-Poly1305 wire envelope)
@@ -104,7 +105,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
104
105
  - **Field-level + crypto-shred** — `b.cryptoField.eraseRow`; per-column and per-row data residency tagging enforced at the write boundary (cross-border DML refused under GDPR / UK-GDPR / DPDP / PIPL / LGPD / APPI / PDPA postures) — on the structured builder, on raw SQL writes (`b.db.runSql` / `b.db.prepare().run()`, parsed quote-aware and failing closed when unparseable), on read-replica fan-out (a regulated read with no row region identified is refused), and surfaced by `b.backup.create` for any per-row-residency table whose admitted regions differ from the backup destination — plus per-row keys (each row's key derives from a CSPRNG row-secret sealed under the vault root, never from an on-disk value) so destroying a row's wrapped secret leaves its WAL / replica / backup residual ciphertext undecryptable even with the vault root key (`b.cryptoField.declareColumnResidency`, `b.cryptoField.declarePerRowResidency`, `b.cryptoField.listPerRowResidency`, `b.cryptoField.declarePerRowKey`)
105
106
  - **AAD-bound sealed columns** — AEAD tag tied to `(table, rowId, column, schemaVersion)`; copy-paste between rows or schema-version replay surfaces as refused decrypt (`b.vault.aad`). The database encryption key is sealed the same way — bound to its purpose, data directory, and key path — so a relocated key file fails to unseal; an older unbound key upgrades itself on first load. A vault-key rotation re-seals every AAD-bound cell, the database key, and tenant archives under the new keypair and refuses rather than silently orphaning a store it cannot reach (`b.vaultRotate`, `b.vault.aad.resealRoot`, `b.archive.rewrapTenant`)
106
107
  - **Keyed lookup hashes** — sealed-column equality-lookup hashes default to salted SHA3-512 and can opt into a keyed `hmac-shake256` MAC off a per-deployment key (`cryptoField.registerTable({ derivedHashMode })`, `b.vault.getDerivedHashMacKey`), making the lookup hash unforgeable and un-correlatable across deployments
107
- - **Signed webhooks + API encryption** — SLH-DSA-SHAKE-256f default; ML-DSA-65 opt-in; ECIES API encryption (`b.webhook`, `b.crypto`)
108
+ - **Signed webhooks + API encryption** — SLH-DSA-SHAKE-256f default; ML-DSA-65 opt-in; ECIES API encryption (`b.webhook`, `b.crypto`); inbound timestamped-HMAC (`t=,v1=`) webhook verification with a replay window, key-rotation multi-signature, and constant-time compare (`b.webhookHmac`); and a public keyed-HMAC primitive with a SHA3-512 default and explicit weaker digests for external-scheme interop (`b.crypto.hmac`)
108
109
  - **HPKE / HTTP signatures** — RFC 9180 HPKE with ML-KEM-1024 + HKDF-SHA3-512 + ChaCha20-Poly1305 (`b.crypto.hpke`); RFC 9421 HTTP Message Signatures with derived components and ed25519 / ML-DSA-65 (`b.crypto.httpSig`); RFC 9530 Content-Digest / Repr-Digest body-integrity fields (SHA-256 / SHA-512, legacy algorithms refused — `b.contentDigest`) to sign the digest rather than the whole body
109
110
  - **X-Wing hybrid KEM** — `b.crypto.xwing` (draft-connolly-cfrg-xwing-kem, experimental): ML-KEM-768 + X25519 bound by SHA3-256, secure if either component holds — the conservative key-encapsulation shape for migrating off classical ECDH. `keygen` / `encapsulate` / `decapsulate` with a 1216-byte public key, 1120-byte ciphertext, and 32-byte shared secret
110
111
  - **Link header** — RFC 8288 Web Linking codec (`b.linkHeader.parse` / `serialize`): parse and build `Link: <uri>; rel="next"` relations, the standard REST pagination mechanism; quote-aware (a comma inside a quoted parameter never splits the list)
@@ -137,6 +138,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
137
138
  - Every access-refusal layer takes a uniform `problemDetails: true` for an RFC 9457 `application/problem+json` body or `onDeny(req, res, info)` to render the refusal itself — so a service can standardize one error envelope across its API without working around hardcoded bodies (`b.problemDetails`)
138
139
  - **Additional middleware** to mount in your `routes` callback: compression, SSE, request logging, request-time DB role binding (`b.middleware.dbRoleFor`), in-process CIDR fence (`b.middleware.networkAllowlist`)
139
140
  - **Outbound HTTP client** — HTTP/1.1 + HTTP/2 with SSRF gate (cloud-metadata IPs hard-denied; private / loopback / link-local overridable per call); scheme + userinfo + per-host destination allowlist; redirects, multipart, interceptors, progress, encrypted cookie jar (`b.httpClient`, `b.ssrfGuard`, `b.safeUrl`)
141
+ - **Local-transport HTTP** — HTTP to a co-located daemon over a Unix domain socket, a Windows named pipe, or a loopback TCP peer with a bearer token — the transports the SSRF gate cannot protect, since there is no network host to steer; refuses any non-loopback host, never forwards Origin / Referer to the daemon, and bounds the response body (`b.localHttp`)
140
142
  - **Network configurability (`b.network`)** — env-driven NTP / NTS (RFC 8915), IPv4/IPv6 NTP, DNS with IPv6 / DoH / DoT (private-CA pinning) / cache / lookup timeout; local DNSSEC signature verification (RFC 4035 — `b.network.dns.dnssec.verifyRrset` over a canonicalised RRset against RSA / ECDSA P-256·P-384 / Ed25519 DNSKEYs, plus DS-digest + key-tag, plus `verifyDenial` for NSEC / NSEC3 (RFC 5155) NXDOMAIN / NODATA proofs with iteration caps + Opt-Out handling, plus `verifyChain` to validate a full root→TLD→zone delegation chain against the pinned IANA root anchors) so a resolver client can verify both positive and negative answers instead of trusting the upstream AD bit; DANE / TLSA certificate matching (RFC 6698/7671 — `b.network.dns.dane.matchCertificate`) to pin a service's key through DNSSEC instead of a public CA; TSIG transaction signatures (RFC 8945 — `b.network.dns.tsig.sign` / `verify`) for shared-key HMAC authentication of zone transfers, dynamic updates, and query/response pairs, with constant-time MAC compare + fudge-window check (verified against dnspython); outbound HTTP proxy (`HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`); runtime DPI trust-store CA additions; application-level heartbeats; TCP socket defaults
141
143
  - **Error pages** — operator-rendered, no app-frame leakage (`b.errorPage`)
142
144
  ### Defensive parsers
package/index.js CHANGED
@@ -135,6 +135,7 @@ var lazyRequire = require("./lib/lazy-require");
135
135
  var frameworkError = require("./lib/framework-error");
136
136
  var nistCrosswalk = require("./lib/nist-crosswalk");
137
137
  var httpClient = require("./lib/http-client");
138
+ var localHttp = require("./lib/local-http");
138
139
  // Attach the encrypted-payload helper from the api-encrypt middleware so
139
140
  // `b.httpClient.encrypted({ pubkey, baseUrl })` is available alongside
140
141
  // the bare `b.httpClient.request(...)`. The api-encrypt module owns the
@@ -433,6 +434,7 @@ var base32 = require("./lib/base32");
433
434
  var uriTemplate = require("./lib/uri-template");
434
435
  var jwk = require("./lib/jwk");
435
436
  var standardWebhooks = require("./lib/standard-webhooks");
437
+ var webhookHmac = require("./lib/webhook-hmac");
436
438
  var lro = require("./lib/lro");
437
439
  var jsonApi = require("./lib/jsonapi");
438
440
  var hal = require("./lib/hal");
@@ -461,6 +463,7 @@ module.exports = {
461
463
  uriTemplate: uriTemplate,
462
464
  jwk: jwk,
463
465
  standardWebhooks: standardWebhooks,
466
+ webhookHmac: webhookHmac,
464
467
  lro: lro,
465
468
  jsonApi: jsonApi,
466
469
  hal: hal,
@@ -546,6 +549,7 @@ module.exports = {
546
549
  lazyRequire: lazyRequire,
547
550
  frameworkError: frameworkError,
548
551
  httpClient: httpClient,
552
+ localHttp: localHttp,
549
553
  websocket: websocket,
550
554
  sse: sse,
551
555
  mcp: mcp,
package/lib/auth/ciba.js CHANGED
@@ -194,7 +194,10 @@ function create(opts) {
194
194
 
195
195
  function _basicAuthHeader() {
196
196
  if (clientAuth !== "secret") return null;
197
- var pair = opts.clientId + ":" + opts.clientSecret;
197
+ // RFC 6749 §2.3.1: percent-encode the id + secret before the ':' join and
198
+ // base64, so a ':' / '+' / '%' / space / non-ASCII byte in the secret can't
199
+ // corrupt the username:password split.
200
+ var pair = encodeURIComponent(opts.clientId) + ":" + encodeURIComponent(opts.clientSecret);
198
201
  return "Basic " + Buffer.from(pair, "utf8").toString("base64");
199
202
  }
200
203
 
package/lib/auth/oauth.js CHANGED
@@ -1129,10 +1129,11 @@ function create(opts) {
1129
1129
  if (!clientId) {
1130
1130
  throw new OAuthError("auth-oauth/no-client-id", "create: opts.clientId is required");
1131
1131
  }
1132
- if (!redirectUri) {
1133
- throw new OAuthError("auth-oauth/no-redirect-uri", "create: opts.redirectUri is required");
1134
- }
1135
- _validateUrl(redirectUri, allowHttp, "redirectUri");
1132
+ // redirectUri is required only for the redirect-based authorization-code / OIDC
1133
+ // flows; a machine-to-machine (client_credentials) client needs none. Validate
1134
+ // it when present; authorizationUrl / exchangeCode enforce its presence at call
1135
+ // time so a mis-configured redirect-flow client still fails loudly.
1136
+ if (redirectUri) _validateUrl(redirectUri, allowHttp, "redirectUri");
1136
1137
 
1137
1138
  // Resolve preset → effective config.
1138
1139
  var preset = null;
@@ -1163,6 +1164,15 @@ function create(opts) {
1163
1164
  : (preset && preset.defaultScope ? preset.defaultScope.slice() : ["openid"]);
1164
1165
  if (!responseMode && preset && preset.responseMode) responseMode = preset.responseMode;
1165
1166
 
1167
+ // RFC 6749 §2.3.1 client authentication at the token endpoint. Default to
1168
+ // client_secret_post (credentials in the request body); client_secret_basic
1169
+ // (HTTP Basic Authorization header) is required by some authorization servers.
1170
+ var tokenEndpointAuthMethod = opts.tokenEndpointAuthMethod || "client_secret_post";
1171
+ if (tokenEndpointAuthMethod !== "client_secret_post" && tokenEndpointAuthMethod !== "client_secret_basic") {
1172
+ throw new OAuthError("auth-oauth/bad-auth-method",
1173
+ "create: tokenEndpointAuthMethod must be 'client_secret_post' or 'client_secret_basic'");
1174
+ }
1175
+
1166
1176
  // Endpoints — either from preset (explicit), discovery, or operator opts.
1167
1177
  var staticEndpoints = {
1168
1178
  authorizationEndpoint: opts.authorizationEndpoint || (preset && preset.authorizationEndpoint) || null,
@@ -1215,7 +1225,7 @@ function create(opts) {
1215
1225
  }, fetchOpts);
1216
1226
  if (allowHttp) req.allowedProtocols = safeUrl.ALLOW_HTTP_ALL;
1217
1227
  if (allowInternal !== null) req.allowInternal = allowInternal;
1218
- Object.assign(req, httpClientOpts);
1228
+ _mergeHttpClientOpts(req);
1219
1229
  var res = await hc.request(req);
1220
1230
  if (res.statusCode < 200 || res.statusCode >= 300) {
1221
1231
  /* c8 ignore next -- httpClient always yields a Buffer body, so the empty-string arm is unreachable */
@@ -1332,6 +1342,10 @@ function create(opts) {
1332
1342
 
1333
1343
  async function authorizationUrl(uopts) {
1334
1344
  uopts = uopts || {};
1345
+ if (!redirectUri) {
1346
+ throw new OAuthError("auth-oauth/no-redirect-uri",
1347
+ "authorizationUrl: a redirectUri must be configured at create() for the authorization-code flow");
1348
+ }
1335
1349
  var endpoint = await _resolveEndpoint("authorizationEndpoint");
1336
1350
  // RFC 9700 §4.13 — refuse an OP whose discovery metadata advertises
1337
1351
  // code_challenge_methods_supported without S256 (PKCE downgrade /
@@ -1401,6 +1415,10 @@ function create(opts) {
1401
1415
 
1402
1416
  async function exchangeCode(eopts) {
1403
1417
  eopts = eopts || {};
1418
+ if (!redirectUri) {
1419
+ throw new OAuthError("auth-oauth/no-redirect-uri",
1420
+ "exchangeCode: a redirectUri must be configured at create() for the authorization-code flow");
1421
+ }
1404
1422
  if (!eopts.code) {
1405
1423
  throw new OAuthError("auth-oauth/no-code", "exchangeCode: opts.code is required");
1406
1424
  }
@@ -1768,6 +1786,9 @@ function create(opts) {
1768
1786
  if (ropts.type) body.set("token_type_hint", ropts.type);
1769
1787
  body.set("client_id", clientId);
1770
1788
  if (clientSecret) body.set("client_secret", clientSecret);
1789
+ // Revocation endpoint (RFC 7009) — authenticate with client_secret_post (the
1790
+ // credentials stay in the body, universally accepted). tokenEndpointAuthMethod
1791
+ // is scoped to the token endpoint; this endpoint may advertise a different one.
1771
1792
  var hc = httpClient;
1772
1793
  var req = {
1773
1794
  url: endpoint,
@@ -1777,7 +1798,7 @@ function create(opts) {
1777
1798
  };
1778
1799
  if (allowHttp) req.allowedProtocols = safeUrl.ALLOW_HTTP_ALL;
1779
1800
  if (allowInternal !== null) req.allowInternal = allowInternal;
1780
- Object.assign(req, httpClientOpts);
1801
+ _mergeHttpClientOpts(req);
1781
1802
  var res = await hc.request(req);
1782
1803
  // RFC 7009: 200 even if the token was already revoked / unknown.
1783
1804
  if (res.statusCode < 200 || res.statusCode >= 300) {
@@ -1786,26 +1807,82 @@ function create(opts) {
1786
1807
  }
1787
1808
  }
1788
1809
 
1789
- async function _postForm(endpoint, body) {
1810
+ // Merge the operator's httpClient options into a request WITHOUT letting an
1811
+ // httpClient.headers config clobber the request's own headers — Content-Type,
1812
+ // Accept, the client Basic Authorization header, Content-Length. The request's
1813
+ // headers win; the operator's are a base default. Applies to every token /
1814
+ // revocation / introspection / registration POST this module builds.
1815
+ function _mergeHttpClientOpts(req) {
1816
+ var ownHeaders = req.headers || {};
1817
+ Object.assign(req, httpClientOpts); // may set req.headers from the operator config (always an object)
1818
+ req.headers = Object.assign({}, req.headers, ownHeaders);
1819
+ }
1820
+
1821
+ // RFC 7231 §7.1.3 Retry-After → milliseconds. Accepts delta-seconds or an
1822
+ // HTTP-date; clamps to [0, 1h]; returns 0 for an unparseable / past value.
1823
+ function _parseRetryAfterMs(ra) {
1824
+ var s = String(ra).trim();
1825
+ if (/^\d+$/.test(s)) return Math.min(parseInt(s, 10), 3600) * C.TIME.seconds(1);
1826
+ var when = Date.parse(s);
1827
+ if (!isNaN(when)) { var d = when - Date.now(); return d > 0 ? Math.min(d, C.TIME.hours(1)) : 0; }
1828
+ return 0;
1829
+ }
1830
+
1831
+ // RFC 6749 §2.3.1 client authentication, applied uniformly to EVERY token-
1832
+ // endpoint POST (not one grant): callers set client_id + client_secret in the
1833
+ // body (client_secret_post). When the client is configured for
1834
+ // client_secret_basic, move the secret into an HTTP Basic Authorization header
1835
+ // — id + secret each percent-encoded (encodeURIComponent) so a ':' / '+' / '%'
1836
+ // / space / non-ASCII byte can't corrupt the username:password split — and out
1837
+ // of the body; client_id stays in the body for authorization-server compat.
1838
+ // Returns the extra header(s) to merge, or null. Mutates `body` (deletes the
1839
+ // secret) only on the Basic path.
1840
+ function _clientBasicAuthHeaders(body) {
1841
+ if (clientSecret && tokenEndpointAuthMethod === "client_secret_basic") {
1842
+ body.delete("client_secret");
1843
+ return { Authorization: "Basic " + Buffer.from(
1844
+ encodeURIComponent(clientId) + ":" + encodeURIComponent(clientSecret), "utf8").toString("base64") };
1845
+ }
1846
+ return null;
1847
+ }
1848
+
1849
+ async function _postForm(endpoint, body, extraHeaders, applyTokenClientAuth) {
1790
1850
  var hc = httpClient;
1851
+ // tokenEndpointAuthMethod (client_secret_basic) is the TOKEN endpoint's client
1852
+ // authentication — apply it only to token-endpoint requests. Other endpoints
1853
+ // (introspection / revocation / PAR / device-authorization) may advertise a
1854
+ // different method, so they authenticate with client_secret_post (credentials
1855
+ // in the body, universally accepted) unless the caller opts in.
1856
+ var authHeaders = applyTokenClientAuth === false ? null : _clientBasicAuthHeaders(body);
1791
1857
  var req = {
1792
1858
  url: endpoint,
1793
1859
  method: "POST",
1794
- headers: {
1860
+ headers: Object.assign({
1795
1861
  "Content-Type": "application/x-www-form-urlencoded",
1796
1862
  "Accept": "application/json",
1797
- },
1863
+ }, authHeaders || {}, extraHeaders || {}),
1798
1864
  body: Buffer.from(body.toString(), "utf8"),
1799
1865
  };
1800
1866
  if (allowHttp) req.allowedProtocols = safeUrl.ALLOW_HTTP_ALL;
1801
1867
  if (allowInternal !== null) req.allowInternal = allowInternal;
1802
- Object.assign(req, httpClientOpts);
1868
+ _mergeHttpClientOpts(req);
1869
+ // Force always-resolve so a non-2xx (e.g. a 429 rate-limit) is surfaced
1870
+ // through this function's typed `auth-oauth/token-error-<status>` mapping
1871
+ // below, NOT the httpClient's own rejection — which downstream callers (the
1872
+ // clientCredentialsManager 429 backoff) cannot classify. This overrides any
1873
+ // operator-supplied responseMode for token-endpoint POSTs.
1874
+ req.responseMode = "always-resolve";
1803
1875
  var res = await hc.request(req);
1804
1876
  /* c8 ignore next -- httpClient always yields a Buffer body, so the empty-string arm is unreachable */
1805
1877
  var text = res.body ? res.body.toString("utf8") : "";
1806
1878
  if (res.statusCode < 200 || res.statusCode >= 300) {
1807
- throw new OAuthError("auth-oauth/token-error-" + res.statusCode,
1879
+ var tokenErr = new OAuthError("auth-oauth/token-error-" + res.statusCode,
1808
1880
  endpoint + " returned " + res.statusCode + ": " + text.slice(0, 500));
1881
+ // Surface a 429 Retry-After (RFC 6585 §4) so the caller can honor the AS's
1882
+ // stated wait instead of a fixed local backoff.
1883
+ var ra = res.headers && (res.headers["retry-after"] !== undefined ? res.headers["retry-after"] : res.headers["Retry-After"]);
1884
+ if (ra !== undefined && ra !== null) tokenErr.retryAfterMs = _parseRetryAfterMs(ra);
1885
+ throw tokenErr;
1809
1886
  }
1810
1887
  var parsed;
1811
1888
  try { parsed = safeJson.parse(text, { maxBytes: OAUTH_MAX_RESPONSE_BYTES }); }
@@ -2210,6 +2287,10 @@ function create(opts) {
2210
2287
  // arrived exactly as the client signed them. Absent → plain-form PAR.
2211
2288
  async function pushAuthorizationRequest(uopts) {
2212
2289
  uopts = uopts || {};
2290
+ if (!redirectUri) {
2291
+ throw new OAuthError("auth-oauth/no-redirect-uri",
2292
+ "pushAuthorizationRequest: a redirectUri must be configured at create() for the authorization-code flow");
2293
+ }
2213
2294
  var endpoint;
2214
2295
  try { endpoint = await _resolveEndpoint("pushedAuthorizationRequestEndpoint"); }
2215
2296
  catch (_e) {
@@ -2307,7 +2388,7 @@ function create(opts) {
2307
2388
  for (var ap = 0; ap < ak.length; ap++) body.set(ak[ap], authzParams[ak[ap]]);
2308
2389
  if (clientSecret) body.set("client_secret", clientSecret);
2309
2390
  }
2310
- var rv = await _postForm(endpoint, body);
2391
+ var rv = await _postForm(endpoint, body, null, false); // PAR endpoint — client_secret_post
2311
2392
  if (!rv || typeof rv.request_uri !== "string" || rv.request_uri.length === 0) {
2312
2393
  throw new OAuthError("auth-oauth/par-bad-response",
2313
2394
  "pushAuthorizationRequest: IdP did not return a request_uri (got " +
@@ -2632,7 +2713,7 @@ function create(opts) {
2632
2713
  if (iopts.tokenTypeHint) body.set("token_type_hint", iopts.tokenTypeHint);
2633
2714
  body.set("client_id", clientId);
2634
2715
  if (clientSecret) body.set("client_secret", clientSecret);
2635
- var parsed = await _postForm(endpoint, body);
2716
+ var parsed = await _postForm(endpoint, body, null, false); // introspection endpoint — client_secret_post
2636
2717
  // RFC 7662 §2.2 — `active` is the only required field; coerce
2637
2718
  // every other interpretation through it.
2638
2719
  if (typeof parsed.active !== "boolean") {
@@ -2718,7 +2799,7 @@ function create(opts) {
2718
2799
  };
2719
2800
  if (allowHttp) req.allowedProtocols = safeUrl.ALLOW_HTTP_ALL;
2720
2801
  if (allowInternal !== null) req.allowInternal = allowInternal;
2721
- Object.assign(req, httpClientOpts);
2802
+ _mergeHttpClientOpts(req);
2722
2803
  var res = await hc.request(req);
2723
2804
  /* c8 ignore next -- httpClient always yields a Buffer body, so the empty-string arm is unreachable */
2724
2805
  var text = res.body ? res.body.toString("utf8") : "";
@@ -2841,7 +2922,7 @@ function create(opts) {
2841
2922
  }
2842
2923
  if (allowHttp) req.allowedProtocols = safeUrl.ALLOW_HTTP_ALL;
2843
2924
  if (allowInternal !== null) req.allowInternal = allowInternal;
2844
- Object.assign(req, httpClientOpts);
2925
+ _mergeHttpClientOpts(req);
2845
2926
  var res = await httpClient.request(req);
2846
2927
  if (method === "DELETE") {
2847
2928
  if (res.statusCode === 204 || res.statusCode === 200) return null;
@@ -2904,7 +2985,7 @@ function create(opts) {
2904
2985
  if (clientSecret) body.set("client_secret", clientSecret);
2905
2986
  var scopes = Array.isArray(dopts.scope) ? dopts.scope : scope;
2906
2987
  if (scopes && scopes.length > 0) body.set("scope", scopes.join(" "));
2907
- var parsed = await _postForm(endpoint, body);
2988
+ var parsed = await _postForm(endpoint, body, null, false); // device-authorization endpoint — client_secret_post
2908
2989
  if (typeof parsed.device_code !== "string" ||
2909
2990
  typeof parsed.user_code !== "string" ||
2910
2991
  typeof parsed.verification_uri !== "string") {
@@ -2966,19 +3047,24 @@ function create(opts) {
2966
3047
  body.set("device_code", deviceCode);
2967
3048
  body.set("client_id", clientId);
2968
3049
  if (clientSecret) body.set("client_secret", clientSecret);
3050
+ // Same RFC 6749 §2.3.1 client authentication as every other token POST —
3051
+ // client_secret_basic moves the secret to a Basic header (this loop builds
3052
+ // its own request rather than routing through _postForm because it must
3053
+ // read the pending/slow_down 400 bodies, so it composes the shared helper).
3054
+ var authHeaders = _clientBasicAuthHeaders(body);
2969
3055
  var hc = httpClient;
2970
3056
  var req = {
2971
3057
  url: endpoint,
2972
3058
  method: "POST",
2973
- headers: {
3059
+ headers: Object.assign({
2974
3060
  "Content-Type": "application/x-www-form-urlencoded",
2975
3061
  "Accept": "application/json",
2976
- },
3062
+ }, authHeaders || {}),
2977
3063
  body: Buffer.from(body.toString(), "utf8"),
2978
3064
  };
2979
3065
  if (allowHttp) req.allowedProtocols = safeUrl.ALLOW_HTTP_ALL;
2980
3066
  if (allowInternal !== null) req.allowInternal = allowInternal;
2981
- Object.assign(req, httpClientOpts);
3067
+ _mergeHttpClientOpts(req);
2982
3068
  // RFC 8628 §3.5 / RFC 6749 §5.2 return the device-grant errors
2983
3069
  // (authorization_pending / slow_down and the terminal codes) as an
2984
3070
  // HTTP 400 whose body carries `error`. The loop below reads that body,
@@ -3190,9 +3276,217 @@ function create(opts) {
3190
3276
  };
3191
3277
  }
3192
3278
 
3279
+ // Resolve the scope for a token request: an explicit override wins, else the
3280
+ // client's configured scope; array or space-joined string → the wire string.
3281
+ // Validate a caller-supplied scope opt is the documented string | string[]
3282
+ // shape. A wrong type (e.g. 42 / {}) would otherwise silently omit scope and
3283
+ // let the AS apply a possibly-broader default; a non-string array member would
3284
+ // coerce through join(). Config-time throw.
3285
+ function _validateScopeOpt(scopeOpt, label) {
3286
+ if (scopeOpt === undefined || scopeOpt === null || typeof scopeOpt === "string") return;
3287
+ if (Array.isArray(scopeOpt) && scopeOpt.every(function (x) { return typeof x === "string"; })) return;
3288
+ throw new OAuthError("auth-oauth/bad-scope",
3289
+ label + ": scope must be a string or an array of strings");
3290
+ }
3291
+
3292
+ function _scopeParam(override) {
3293
+ // Client-credentials only: the caller always supplies an explicit
3294
+ // override (null when no scope), so there is deliberately no fallback to
3295
+ // the create()-configured scope — M2M must not inherit the authorization
3296
+ // -code default scope.
3297
+ var s = override;
3298
+ if (Array.isArray(s)) return s.length > 0 ? s.join(" ") : null;
3299
+ if (typeof s === "string" && s.length > 0) return s;
3300
+ return null;
3301
+ }
3302
+
3303
+ /**
3304
+ * @primitive b.auth.oauth.clientCredentials
3305
+ * @signature b.auth.oauth.clientCredentials(opts?)
3306
+ * @since 0.18.8
3307
+ * @status stable
3308
+ * @related b.auth.oauth.clientCredentialsManager
3309
+ *
3310
+ * RFC 6749 §4.4 <code>client_credentials</code> grant — machine-to-machine
3311
+ * authentication with no user present. POSTs
3312
+ * <code>grant_type=client_credentials</code> to the token endpoint,
3313
+ * authenticating with the client id/secret via <code>client_secret_post</code>
3314
+ * (default) or <code>client_secret_basic</code>, and returns the access token.
3315
+ * A machine-to-machine request sends NO scope unless you supply one — the
3316
+ * client's authorization-code scope default is not applied. Per the RFC a
3317
+ * refresh_token is NEVER issued for this grant — present the credentials again
3318
+ * to renew (see <code>clientCredentialsManager</code> for a cached,
3319
+ * auto-renewing wrapper). A client_credentials-only client needs no
3320
+ * <code>redirectUri</code> at <code>create</code>.
3321
+ *
3322
+ * Resolves <code>{ accessToken, tokenType, expiresIn, expiresAt, scope }</code>
3323
+ * where <code>expiresAt</code> is an epoch-ms deadline (or null when the AS
3324
+ * omits <code>expires_in</code>).
3325
+ *
3326
+ * @opts
3327
+ * scope: string[] | string, // scope for this token (default: none — a machine-to-machine request sends no scope)
3328
+ *
3329
+ * @example
3330
+ * var t = await oauth.clientCredentials();
3331
+ * await fetch(api, { headers: { Authorization: "Bearer " + t.accessToken } });
3332
+ */
3333
+ async function clientCredentials(ccopts) {
3334
+ ccopts = validateOpts.requireObject(ccopts === undefined ? {} : ccopts,
3335
+ "clientCredentials", OAuthError, "auth-oauth/bad-opts");
3336
+ validateOpts(ccopts, ["scope"], "clientCredentials");
3337
+ _validateScopeOpt(ccopts.scope, "clientCredentials");
3338
+ if (!clientSecret) {
3339
+ throw new OAuthError("auth-oauth/no-client-secret",
3340
+ "clientCredentials: a clientSecret is required for the client_credentials grant");
3341
+ }
3342
+ var endpoint = await _resolveEndpoint("tokenEndpoint");
3343
+ var body = new URLSearchParams();
3344
+ body.set("grant_type", "client_credentials");
3345
+ body.set("client_id", clientId);
3346
+ body.set("client_secret", clientSecret);
3347
+ // Machine-to-machine requests carry NO scope by default — the client's
3348
+ // configured (authorization-code / OIDC) scope like "openid" is meaningless
3349
+ // for client_credentials, so only an explicitly-supplied scope is sent. Client
3350
+ // authentication (client_secret_post vs _basic) is applied uniformly in
3351
+ // _postForm from the client's configured tokenEndpointAuthMethod.
3352
+ var reqScope = _scopeParam(ccopts.scope === undefined ? null : ccopts.scope);
3353
+ if (reqScope) body.set("scope", reqScope);
3354
+ var raw = await _postForm(endpoint, body);
3355
+ if (!raw || typeof raw.access_token !== "string" || raw.access_token.length === 0) {
3356
+ throw new OAuthError("auth-oauth/no-access-token",
3357
+ "clientCredentials: token endpoint response has no access_token");
3358
+ }
3359
+ var expiresIn = (typeof raw.expires_in === "number" && isFinite(raw.expires_in) && raw.expires_in > 0)
3360
+ ? Math.floor(raw.expires_in) : null;
3361
+ return {
3362
+ accessToken: raw.access_token,
3363
+ tokenType: typeof raw.token_type === "string" ? raw.token_type : "Bearer",
3364
+ expiresIn: expiresIn,
3365
+ expiresAt: expiresIn !== null ? Date.now() + C.TIME.seconds(expiresIn) : null,
3366
+ scope: typeof raw.scope === "string" ? raw.scope : reqScope,
3367
+ };
3368
+ }
3369
+
3370
+ /**
3371
+ * @primitive b.auth.oauth.clientCredentialsManager
3372
+ * @signature b.auth.oauth.clientCredentialsManager(opts?)
3373
+ * @since 0.18.8
3374
+ * @status stable
3375
+ * @related b.auth.oauth.clientCredentials
3376
+ *
3377
+ * A memory-cached <code>client_credentials</code> token manager.
3378
+ * <code>getToken()</code> returns a valid bearer access token — fetching one
3379
+ * on first use and re-fetching <code>refreshSkewSec</code> before expiry (60s
3380
+ * by default). Concurrent <code>getToken()</code> calls during a fetch share
3381
+ * ONE in-flight request (no thundering herd). A 429 from the token endpoint
3382
+ * opens a short backoff window during which the still-cached token is served
3383
+ * rather than hammering the AS. State is per-manager and in-memory only —
3384
+ * seal the long-lived <code>clientSecret</code> at rest yourself.
3385
+ *
3386
+ * @opts
3387
+ * scope: string[] | string, // override the client's scope
3388
+ * refreshSkewSec: number, // re-fetch this many seconds before expiry (default: 60)
3389
+ * backoffSec: number, // 429 backoff window (default: 30)
3390
+ *
3391
+ * @example
3392
+ * var mgr = oauth.clientCredentialsManager();
3393
+ * var token = await mgr.getToken(); // cached + auto-renewed
3394
+ */
3395
+ function clientCredentialsManager(mopts) {
3396
+ mopts = validateOpts.requireObject(mopts === undefined ? {} : mopts,
3397
+ "clientCredentialsManager", OAuthError, "auth-oauth/bad-opts");
3398
+ validateOpts(mopts, ["scope", "refreshSkewSec", "backoffSec"], "clientCredentialsManager");
3399
+ _validateScopeOpt(mopts.scope, "clientCredentialsManager");
3400
+ numericBounds.requirePositiveFiniteIntIfPresent(mopts.refreshSkewSec, "refreshSkewSec",
3401
+ OAuthError, "auth-oauth/bad-refresh-skew");
3402
+ numericBounds.requirePositiveFiniteIntIfPresent(mopts.backoffSec, "backoffSec",
3403
+ OAuthError, "auth-oauth/bad-backoff");
3404
+ var skewMs = typeof mopts.refreshSkewSec === "number" ? C.TIME.seconds(mopts.refreshSkewSec) : C.TIME.minutes(1);
3405
+ var backoffMs = typeof mopts.backoffSec === "number" ? C.TIME.seconds(mopts.backoffSec) : C.TIME.seconds(30);
3406
+ var cached = null; // { accessToken, expiresAt, lifetimeMs }
3407
+ var inflight = null; // shared Promise while a fetch is running
3408
+ var backoffUntil = 0; // epoch-ms; a transient-failure backoff is active while now < this
3409
+
3410
+ function _isTransientRefreshError(e) {
3411
+ // A refresh failure is TRANSIENT (safe to ride out on the still-valid cache)
3412
+ // ONLY for a KNOWN transient error — a 429 or 5xx from the token endpoint,
3413
+ // or a transport / network failure (b.httpClient marks those permanent:false).
3414
+ // Everything else propagates: a 4xx (invalid_client / invalid_scope), a
3415
+ // malformed response, or a permanent config error like no-endpoint (all
3416
+ // OAuthError, permanent:true) — masking those would hide the actionable
3417
+ // error and repeat it. Default-to-permanent, allowlist transient.
3418
+ /* c8 ignore next -- e is always a coded error from the refresh; the null guard is defensive */
3419
+ if (!e) return false;
3420
+ // e.code is always a non-empty string here (every refresh error is a
3421
+ // coded OAuthError / HttpClientError); exec coerces a missing code to the
3422
+ // literal "undefined", which never matches, so no "|| ''" guard is needed.
3423
+ var m = /^auth-oauth\/token-error-(\d+)$/.exec(e.code);
3424
+ if (m) { var s = parseInt(m[1], 10); return s === 429 || s >= 500; }
3425
+ return e.permanent === false;
3426
+ }
3427
+
3428
+ function _usable(now) {
3429
+ // A token whose expiry the AS did not state (no expires_in) is NOT cached
3430
+ // — re-fetch each call rather than serve a token that may already be dead.
3431
+ if (!cached || cached.expiresAt === null || cached.expiresAt <= now) return false;
3432
+ // Normally refresh skewMs before expiry. A token whose whole lifetime is
3433
+ // <= skewMs can never satisfy that (it is born inside the skew window), so
3434
+ // it uses a PROPORTIONAL margin (half its lifetime) — cached + reused, but
3435
+ // still refreshed before it dies rather than served to the last millisecond.
3436
+ var effectiveSkew = (cached.lifetimeMs !== null && cached.lifetimeMs <= skewMs)
3437
+ ? cached.lifetimeMs / 2
3438
+ : skewMs;
3439
+ return cached.expiresAt - now > effectiveSkew;
3440
+ }
3441
+ function _servableDuringBackoff(now) {
3442
+ // During a 429 backoff we may ride out on the STILL-VALID cached token, but
3443
+ // never an already-expired one — serving a dead token is worse than
3444
+ // surfacing the rate-limit error and letting the caller retry.
3445
+ return cached && cached.expiresAt !== null && cached.expiresAt > now;
3446
+ }
3447
+ async function getToken() {
3448
+ var now = Date.now();
3449
+ if (_usable(now)) return cached.accessToken;
3450
+ // Inside a 429 backoff window: serve the still-valid cached token, else
3451
+ // fail fast WITHOUT a network call. No token endpoint request is made for
3452
+ // the whole backoff window — re-hammering it is exactly what the backoff
3453
+ // exists to prevent.
3454
+ if (now < backoffUntil) {
3455
+ if (_servableDuringBackoff(now)) return cached.accessToken;
3456
+ throw new OAuthError("auth-oauth/backoff-active",
3457
+ "clientCredentialsManager: token endpoint is in 429 backoff and no still-valid cached token is available");
3458
+ }
3459
+ if (inflight) return inflight;
3460
+ inflight = clientCredentials({ scope: mopts.scope }).then(function (t) {
3461
+ cached = { accessToken: t.accessToken, expiresAt: t.expiresAt,
3462
+ lifetimeMs: t.expiresIn !== null ? C.TIME.seconds(t.expiresIn) : null };
3463
+ backoffUntil = 0;
3464
+ inflight = null;
3465
+ return t.accessToken;
3466
+ }, function (e) {
3467
+ inflight = null;
3468
+ if (_isTransientRefreshError(e)) {
3469
+ // Transient failure (429 / 5xx / transport): open a backoff so repeated
3470
+ // failures don't hammer the endpoint — honor a 429 Retry-After (RFC 6585
3471
+ // §4), else the fixed backoff — and ride out on the still-valid cached
3472
+ // token. A PERMANENT error skips both and propagates so it is detected.
3473
+ var waitMs = (e.code === "auth-oauth/token-error-429" && typeof e.retryAfterMs === "number" && e.retryAfterMs > 0)
3474
+ ? e.retryAfterMs : backoffMs;
3475
+ backoffUntil = Date.now() + waitMs;
3476
+ if (_servableDuringBackoff(Date.now())) return cached.accessToken;
3477
+ }
3478
+ throw e;
3479
+ });
3480
+ return inflight;
3481
+ }
3482
+ return { getToken: getToken };
3483
+ }
3484
+
3193
3485
  return {
3194
3486
  authorizationUrl: authorizationUrl,
3195
3487
  exchangeCode: exchangeCode,
3488
+ clientCredentials: clientCredentials,
3489
+ clientCredentialsManager: clientCredentialsManager,
3196
3490
  refreshAccessToken: refreshAccessToken,
3197
3491
  fetchUserInfo: fetchUserInfo,
3198
3492
  revokeToken: revokeToken,
@@ -487,7 +487,7 @@ var DERIVED_HASH_BYTES = 32;
487
487
  // (64 hex). The key is a vault-derived secret, NOT a static salt, so an
488
488
  // attacker who recovers the salt alone can't correlate two low-entropy
489
489
  // plaintexts; the sponge has no length-extension weakness.
490
- // (b.crypto.hmacSha3 (HMAC-SHA3-512) was considered; SHAKE256(key||msg)
490
+ // (b.crypto.hmac (HMAC-SHA3-512) was considered; SHAKE256(key||msg)
491
491
  // is chosen for the fixed-width keyed digest with the same MAC-grade
492
492
  // guarantee.) FIPS 202; NIST SP 800-185; GDPR Art. 4(5)
493
493
  // pseudonymisation; HIPAA 45 CFR 164.514(b).