@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 +4 -0
- package/README.md +3 -1
- package/index.js +4 -0
- package/lib/auth/ciba.js +4 -1
- package/lib/auth/oauth.js +313 -19
- package/lib/crypto-field.js +1 -1
- package/lib/crypto.js +55 -24
- package/lib/db-collection.js +7 -0
- package/lib/http-client.js +9 -12
- package/lib/local-http.js +347 -0
- package/lib/request-helpers.js +126 -4
- package/lib/webhook-hmac.js +206 -0
- package/lib/webhook.js +2 -2
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
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
|
-
|
|
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
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/lib/crypto-field.js
CHANGED
|
@@ -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.
|
|
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).
|